first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,12 @@
node_modules
.env
.env.*
!.env.example
!.env.*.example
__pycache__
*.py[cod]
.pytest_cache
tmp
!dist
!dist/**
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
import{L as z,F as I,B as H,C as P,a as G,V as _,b as N}from"./three.module-BB51Dq4E.js";class O extends z{constructor(h){super(h)}load(h,T,V,y){const b=this,m=new I(this.manager);m.setPath(this.path),m.setResponseType("arraybuffer"),m.setRequestHeader(this.requestHeader),m.setWithCredentials(this.withCredentials),m.load(h,function(v){try{T(b.parse(v))}catch(A){y?y(A):console.error(A),b.manager.itemError(h)}},V,y)}parse(h){function T(t){const e=new DataView(t),o=32/8*3+32/8*3*3+16/8,a=e.getUint32(80,!0);if(80+32/8+a*o===e.byteLength)return!0;const i=[115,111,108,105,100];for(let s=0;s<5;s++)if(V(i,e,s))return!1;return!0}function V(t,e,o){for(let a=0,f=t.length;a<f;a++)if(t[a]!==e.getUint8(o+a))return!1;return!0}function y(t){const e=new DataView(t),o=e.getUint32(80,!0);let a,f,i,s=!1,w,S,C,L,U;for(let n=0;n<70;n++)e.getUint32(n,!1)==1129270351&&e.getUint8(n+4)==82&&e.getUint8(n+5)==61&&(s=!0,w=new Float32Array(o*3*3),S=e.getUint8(n+6)/255,C=e.getUint8(n+7)/255,L=e.getUint8(n+8)/255,U=e.getUint8(n+9)/255);const g=84,r=50,c=new H,p=new Float32Array(o*3*3),d=new Float32Array(o*3*3),x=new P;for(let n=0;n<o;n++){const F=g+n*r,D=e.getFloat32(F,!0),B=e.getFloat32(F+4,!0),R=e.getFloat32(F+8,!0);if(s){const l=e.getUint16(F+48,!0);(l&32768)===0?(a=(l&31)/31,f=(l>>5&31)/31,i=(l>>10&31)/31):(a=S,f=C,i=L)}for(let l=1;l<=3;l++){const E=F+l*12,u=n*3*3+(l-1)*3;p[u]=e.getFloat32(E,!0),p[u+1]=e.getFloat32(E+4,!0),p[u+2]=e.getFloat32(E+8,!0),d[u]=D,d[u+1]=B,d[u+2]=R,s&&(x.set(a,f,i).convertSRGBToLinear(),w[u]=x.r,w[u+1]=x.g,w[u+2]=x.b)}}return c.setAttribute("position",new G(p,3)),c.setAttribute("normal",new G(d,3)),s&&(c.setAttribute("color",new G(w,3)),c.hasColors=!0,c.alpha=U),c}function b(t){const e=new H,o=/solid([\s\S]*?)endsolid/g,a=/facet([\s\S]*?)endfacet/g,f=/solid\s(.+)/;let i=0;const s=/[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source,w=new RegExp("vertex"+s+s+s,"g"),S=new RegExp("normal"+s+s+s,"g"),C=[],L=[],U=[],g=new _;let r,c=0,p=0,d=0;for(;(r=o.exec(t))!==null;){p=d;const x=r[0],n=(r=f.exec(x))!==null?r[1]:"";for(U.push(n);(r=a.exec(x))!==null;){let B=0,R=0;const l=r[0];for(;(r=S.exec(l))!==null;)g.x=parseFloat(r[1]),g.y=parseFloat(r[2]),g.z=parseFloat(r[3]),R++;for(;(r=w.exec(l))!==null;)C.push(parseFloat(r[1]),parseFloat(r[2]),parseFloat(r[3])),L.push(g.x,g.y,g.z),B++,d++;R!==1&&console.error("THREE.STLLoader: Something isn't right with the normal of face number "+i),B!==3&&console.error("THREE.STLLoader: Something isn't right with the vertices of face number "+i),i++}const F=p,D=d-p;e.userData.groupNames=U,e.addGroup(F,D,c),c++}return e.setAttribute("position",new N(C,3)),e.setAttribute("normal",new N(L,3)),e}function m(t){return typeof t!="string"?new TextDecoder().decode(t):t}function v(t){if(typeof t=="string"){const e=new Uint8Array(t.length);for(let o=0;o<t.length;o++)e[o]=t.charCodeAt(o)&255;return e.buffer||e}else return t}const A=v(h);return T(A)?y(A):b(m(h))}}export{O as STLLoader};
//# sourceMappingURL=STLLoader-C7hGonJR.js.map
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
function c(r){r.computeBoundingBox();const t=r.boundingBox?.min,e=r.boundingBox?.max;return!t||!e?{min:[0,0,0],max:[0,0,0]}:{min:[t.x,t.y,t.z],max:[e.x,e.y,e.z]}}const l=Math.PI/4;function f(r,t){const e=r.clone();e.deleteAttribute?.("normal");const o=typeof t=="function"?t(e,l):e;return o!==e&&e.dispose?.(),o.getAttribute("normal")||o.computeVertexNormals?.(),o}function u(r,t){const e=r.getIndex?.();if(e?.count>0)return new Uint32Array(e.array);const o=new Uint32Array(t);for(let n=0;n<t;n+=1)o[n]=n;return o}function d(r,{toCreasedNormals:t}={}){const e=f(r,t),o=r.getAttribute("position"),n=e.getAttribute("position"),s=e.getAttribute("normal");try{if(!o||o.itemSize!==3||!n||n.itemSize!==3)throw new Error("STL geometry is missing vertex positions");const a=n.count;return{vertices:new Float32Array(n.array),indices:u(e,a),normals:s?.itemSize===3?new Float32Array(s.array):new Float32Array(n.array.length),colors:new Float32Array(0),edge_indices:new Uint32Array(0),bounds:c(r),parts:[],has_source_colors:!1,sourceColor:"",sourceFormat:"stl"}}finally{e.dispose?.()}}async function y(r){const[{STLLoader:t},{toCreasedNormals:e}]=await Promise.all([import("./STLLoader-C7hGonJR.js"),import("./BufferGeometryUtils-xu92ooFc.js")]),n=new t().parse(r);try{return d(n,{toCreasedNormals:e})}finally{n.dispose?.()}}function m(r){const t=[r?.vertices?.buffer,r?.indices?.buffer,r?.normals?.buffer,r?.colors?.buffer,r?.edge_indices?.buffer,r?.surfaceEdgeBarycentric?.buffer,r?.surfaceEdgeClass?.buffer,r?.guide_line_segments?.buffer].filter(e=>e instanceof ArrayBuffer&&e.byteLength>0);return[...new Set(t)]}const i=new Map;function b(r,t){return new Error(`Failed to fetch ${r}: ${t.status} ${t.statusText}`)}async function g(r,t){const e=await fetch(r,{signal:t});if(!e.ok)throw b(r,e);return e.arrayBuffer()}self.addEventListener("message",async r=>{const t=r.data||{},e=t.id;if(!e)return;if(t.type==="cancel"){i.get(e)?.abort(),i.delete(e);return}if(t.type!=="loadStl")return;const o=new AbortController;i.set(e,o);try{const n=await g(t.url,o.signal),s=await y(n);if(o.signal.aborted)return;self.postMessage({id:e,ok:!0,meshData:s},m(s))}catch(n){o.signal.aborted||self.postMessage({id:e,ok:!1,error:{name:n?.name||"Error",message:n instanceof Error?n.message:String(n)}})}finally{i.delete(e)}});
//# sourceMappingURL=stlMeshWorker-0yVf-FuZ.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Browse cached CAD parts, assemblies, and prompt-ready references" />
<title>CAD Viewer</title>
<script>
(function () {
const root = document.documentElement;
const storageKey = "cad-viewer:color-scheme";
const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
let preference = "system";
try {
const storedPreference = window.localStorage && window.localStorage.getItem(storageKey);
if (storedPreference === "light" || storedPreference === "dark" || storedPreference === "system") {
preference = storedPreference;
}
} catch {
preference = "system";
}
const mode = preference === "system" ? (prefersDark ? "dark" : "light") : preference;
root.dataset.themePreference = preference;
root.dataset.theme = mode;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
})();
</script>
<script type="module" crossorigin src="/assets/index-BkFzql5p.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-react-7jnMmQ9s.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-ui-CtsphK4U.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-icons-FFp724MI.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-three-SYzIooV9.js">
<link rel="stylesheet" crossorigin href="/assets/index-vRirKMey.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,3 @@
from pathlib import Path
__path__.append(str(Path(__file__).resolve().parent / "moveit2_server"))
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=conda-common.sh
source "${SCRIPT_DIR}/conda-common.sh"
REPO_ROOT="$(find_repo_root)"
CONDA="$(find_conda)"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m moveit2_server.server --check --repo-root "${REPO_ROOT}"
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
MOVEIT2_SERVER_CONDA_ENV_NAME="${MOVEIT2_SERVER_CONDA_ENV_NAME:-moveit2-server}"
find_conda() {
if [[ -n "${MOVEIT2_SERVER_CONDA_EXE:-}" && -x "${MOVEIT2_SERVER_CONDA_EXE}" ]]; then
printf '%s\n' "${MOVEIT2_SERVER_CONDA_EXE}"
return 0
fi
if [[ -n "${CONDA_EXE:-}" && -x "${CONDA_EXE}" ]]; then
printf '%s\n' "${CONDA_EXE}"
return 0
fi
if command -v conda >/dev/null 2>&1; then
command -v conda
return 0
fi
local candidate
for candidate in \
"${HOME}/miniforge3/bin/conda" \
"${HOME}/mambaforge/bin/conda" \
"${HOME}/miniconda3/bin/conda" \
"${HOME}/anaconda3/bin/conda" \
"/opt/conda/bin/conda"
do
if [[ -x "${candidate}" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
done
printf 'Could not find conda. Install Miniforge, put conda on PATH, or set MOVEIT2_SERVER_CONDA_EXE/CONDA_EXE.\n' >&2
return 1
}
find_repo_root() {
if [[ -n "${MOVEIT2_SERVER_REPO_ROOT:-}" ]]; then
if [[ ! -d "${MOVEIT2_SERVER_REPO_ROOT}" ]]; then
printf 'MOVEIT2_SERVER_REPO_ROOT does not exist: %s\n' "${MOVEIT2_SERVER_REPO_ROOT}" >&2
return 1
fi
(cd "${MOVEIT2_SERVER_REPO_ROOT}" && pwd -P)
return 0
fi
local root
if root="$(git -C "${PWD}" rev-parse --show-toplevel 2>/dev/null)"; then
printf '%s\n' "${root}"
return 0
fi
printf 'Could not find a repository root from the current directory. Run from the robot project repo or set MOVEIT2_SERVER_REPO_ROOT.\n' >&2
return 1
}
@@ -0,0 +1,14 @@
name: moveit2-server
channels:
- conda-forge
- robostack-jazzy
dependencies:
- python=3.12
- pip
- websockets
- ros-jazzy-ros-base
- ros-jazzy-rclpy
- ros-jazzy-geometry-msgs
- ros-jazzy-moveit-py
- ros-jazzy-moveit-planners-ompl
- ros-jazzy-rmw-cyclonedds-cpp
@@ -0,0 +1 @@
"""MoveIt2 websocket server for SRDF workflows."""
@@ -0,0 +1,904 @@
from __future__ import annotations
from math import isfinite
import posixpath
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from moveit2_server.protocol import MotionProtocolError, SUPPORTED_REQUEST_TYPES
from moveit2_server.srdf_source import SrdfSource, read_srdf_source
DEFAULT_CAD_DIRECTORY = ""
DEFAULT_IK_TIMEOUT = 0.05
DEFAULT_IK_ATTEMPTS = 1
DEFAULT_POSITION_TOLERANCE = 0.002
DEFAULT_PLANNING_PIPELINE = "ompl"
DEFAULT_PLANNER_ID = "RRTConnectkConfigDefault"
DEFAULT_PLANNING_TIME = 1.0
def _plain_object(value: Any) -> bool:
return isinstance(value, dict)
def _path_is_inside(child_path: Path, parent_path: Path) -> bool:
try:
child_path.resolve().relative_to(parent_path.resolve())
return True
except ValueError:
return False
def _file_version(file_path: Path) -> str:
try:
stats = file_path.stat()
except FileNotFoundError:
return ""
if not stats.st_mode:
return ""
return f"{stats.st_size:x}-{stats.st_mtime_ns:x}"
def _combined_version(paths: list[Path]) -> str:
return "|".join(f"{path.name}:{_file_version(path)}" for path in paths)
def _normalize_relative_path(value: Any, *, label: str, suffix: str | None = None) -> str:
raw_value = str(value or "").strip().replace("\\", "/").lstrip("/")
normalized = posixpath.normpath(raw_value)
if not raw_value or normalized in {"", ".", ".."} or normalized.startswith("../"):
raise MotionProtocolError(f"{label} must stay inside the repository: {raw_value or '(missing)'}")
if suffix and not normalized.lower().endswith(suffix):
raise MotionProtocolError(f"{label} must end in {suffix}: {normalized}")
return normalized
def normalize_cad_directory(value: Any = DEFAULT_CAD_DIRECTORY) -> str:
if value is None:
value = DEFAULT_CAD_DIRECTORY
raw_value = str(value).strip()
if not raw_value:
return ""
return _normalize_relative_path(raw_value, label="dir")
def normalize_file_ref(value: Any) -> str:
return _normalize_relative_path(value, label="file", suffix=".srdf")
def _file_ref_relative_to_cad_dir(file_ref: str, *, cad_dir: str, cad_root: Path) -> str:
if not cad_dir:
return file_ref
prefix = f"{cad_dir.rstrip('/')}/"
if not file_ref.startswith(prefix):
return file_ref
scan_relative_ref = file_ref[len(prefix):]
if scan_relative_ref and not (cad_root / file_ref).is_file() and (cad_root / scan_relative_ref).is_file():
return scan_relative_ref
return file_ref
def _resolve_srdf_urdf_path(srdf_source: SrdfSource, *, srdf_path: Path, repo_root: Path) -> Path:
raw_value = str(srdf_source.urdf_ref or "").strip()
if not raw_value:
raise MotionProtocolError("SRDF must include tcad:urdf metadata")
if "\\" in raw_value:
raise MotionProtocolError("SRDF tcad:urdf path must use POSIX '/' separators")
raw_path = Path(raw_value)
if raw_path.is_absolute():
raise MotionProtocolError("SRDF tcad:urdf path must be relative")
candidate = (srdf_path.parent / raw_path).resolve()
if not _path_is_inside(candidate, repo_root):
raise MotionProtocolError(f"SRDF tcad:urdf path must stay inside the repository: {raw_value}")
if candidate.suffix.lower() != ".urdf":
raise MotionProtocolError(f"SRDF tcad:urdf path must end in .urdf: {raw_value}")
if not candidate.is_file():
raise MotionProtocolError(f"SRDF tcad:urdf file does not exist: {raw_value}")
return candidate
def _urdf_robot(urdf_path: Path) -> dict[str, Any]:
try:
root = ET.parse(urdf_path).getroot()
except FileNotFoundError as exc:
raise MotionProtocolError(f"URDF file does not exist: {urdf_path}") from exc
except ET.ParseError as exc:
raise MotionProtocolError(f"URDF file is invalid XML: {urdf_path}") from exc
if root.tag != "robot":
raise MotionProtocolError("URDF root must be <robot>")
links = {
str(link.get("name") or "").strip()
for link in root.findall("link")
if str(link.get("name") or "").strip()
}
joints: dict[str, dict[str, str]] = {}
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
if not name:
continue
parent_element = joint.find("parent")
child_element = joint.find("child")
joint_type = str(joint.get("type") or "").strip()
limit_element = joint.find("limit")
lower = _optional_finite_float(limit_element.get("lower") if limit_element is not None else None)
upper = _optional_finite_float(limit_element.get("upper") if limit_element is not None else None)
joints[name] = {
"type": joint_type,
"parent": str(parent_element.get("link") if parent_element is not None else "").strip(),
"child": str(child_element.get("link") if child_element is not None else "").strip(),
"lower": lower if joint_type in {"revolute", "prismatic"} else None,
"upper": upper if joint_type in {"revolute", "prismatic"} else None,
"mimic": joint.find("mimic") is not None,
}
return {
"name": str(root.get("name") or "").strip(),
"links": links,
"joints": joints,
}
def _optional_finite_float(value: Any) -> float | None:
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if isfinite(parsed) else None
def _active_joint_names(robot: dict[str, Any]) -> list[str]:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
return [
str(name)
for name, joint in joints.items()
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic"))
]
def _all_joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
if not base_link or not tip_link:
return []
if base_link == tip_link:
return []
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
by_parent: dict[str, list[tuple[str, dict[str, Any]]]] = {}
for raw_name, raw_joint in joints.items():
if not isinstance(raw_joint, dict):
continue
parent = str(raw_joint.get("parent") or "").strip()
child = str(raw_joint.get("child") or "").strip()
if parent and child:
by_parent.setdefault(parent, []).append((str(raw_name), raw_joint))
stack: list[tuple[str, list[tuple[str, dict[str, Any]]]]] = [(base_link, [])]
visited: set[str] = set()
while stack:
link_name, path = stack.pop()
if link_name == tip_link:
return [joint_name for joint_name, _joint in path]
if link_name in visited:
continue
visited.add(link_name)
for joint_name, joint in reversed(by_parent.get(link_name, [])):
child = str(joint.get("child") or "").strip()
if child:
stack.append((child, [*path, (joint_name, joint)]))
return []
def _joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
names: list[str] = []
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
joint = joints.get(joint_name)
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic")):
names.append(joint_name)
return names
def _append_unique(target: list[str], values: list[str]) -> None:
seen = set(target)
for value in values:
if value not in seen:
target.append(value)
seen.add(value)
def _joint_names_for_group(
group: dict[str, Any],
*,
robot: dict[str, Any],
groups_by_name: dict[str, dict[str, Any]],
visited: set[str] | None = None,
) -> list[str]:
explicit_joint_names = list(group.get("jointNames") or [])
if explicit_joint_names:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
return [
str(name)
for name in explicit_joint_names
if isinstance(joints.get(str(name)), dict)
and str(joints[str(name)].get("type") or "").strip() != "fixed"
and not bool(joints[str(name)].get("mimic"))
]
names: list[str] = []
for chain in group.get("chains") or []:
if not isinstance(chain, dict):
continue
_append_unique(
names,
_joint_names_for_chain(
robot,
base_link=str(chain.get("baseLink") or "").strip(),
tip_link=str(chain.get("tipLink") or "").strip(),
),
)
if visited is None:
visited = set()
group_name = str(group.get("name") or "").strip()
if group_name:
visited.add(group_name)
for subgroup_name in group.get("subgroups") or []:
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
_append_unique(
names,
_joint_names_for_group(
subgroup,
robot=robot,
groups_by_name=groups_by_name,
visited=visited,
),
)
return names
def _positive_float(value: Any, *, label: str, default: float) -> float:
if value is None:
value = default
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be positive") from exc
if numeric <= 0:
raise MotionProtocolError(f"{label} must be positive")
return numeric
def _positive_int(value: Any, *, label: str, default: int) -> int:
if value is None:
value = default
try:
numeric = int(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be a positive integer") from exc
if numeric <= 0:
raise MotionProtocolError(f"{label} must be a positive integer")
return numeric
def _scaling(value: Any, *, label: str, default: float) -> float:
if value is None:
value = default
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be between 0 and 1") from exc
if numeric <= 0 or numeric > 1:
raise MotionProtocolError(f"{label} must be between 0 and 1")
return numeric
def _boolean(value: Any, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def _string(value: Any, *, label: str, default: str = "") -> str:
normalized = str(value if value is not None else default).strip()
if not normalized:
raise MotionProtocolError(f"{label} is required")
return normalized
def _optional_object(value: Any, *, label: str) -> dict[str, Any]:
if value is None:
return {}
if not _plain_object(value):
raise MotionProtocolError(f"{label} must be an object")
return value
def _validate_srdf_inventory(metadata: dict[str, Any], *, robot: dict[str, Any]) -> dict[str, Any]:
if str(metadata.get("robotName") or "").strip() != str(robot.get("name") or "").strip():
raise MotionProtocolError("SRDF robotName must match the linked URDF robot name")
link_names = robot.get("links")
joint_payload = robot.get("joints")
if not isinstance(link_names, set) or not isinstance(joint_payload, dict):
raise MotionProtocolError("URDF robot inventory is invalid")
joint_names = set(joint_payload)
groups = _validate_planning_groups(metadata.get("planningGroups"), link_names=link_names, joint_names=joint_names)
group_by_name = {group["name"]: group for group in groups}
end_effectors = _validate_end_effectors(
metadata.get("endEffectors"),
link_names=link_names,
groups=group_by_name,
robot=robot,
)
group_states = _validate_group_states(metadata.get("groupStates"), groups=group_by_name, robot=robot)
disabled_pairs = _validate_disabled_pairs(metadata.get("disabledCollisionPairs"), link_names=link_names)
return {
**metadata,
"planningGroups": groups,
"endEffectors": end_effectors,
"groupStates": group_states,
"disabledCollisionPairs": disabled_pairs,
}
def _group_tip_link(group: Any) -> str:
if isinstance(group, dict):
link_names = group.get("linkNames")
if isinstance(link_names, list) and link_names:
return str(link_names[-1])
chains = group.get("chains")
if isinstance(chains, list) and chains:
chain = chains[-1]
if isinstance(chain, dict):
return str(chain.get("tipLink") or "")
return ""
link_names = getattr(group, "link_names", ())
if link_names:
return str(link_names[-1])
chains = getattr(group, "chains", ())
if chains:
return str(getattr(chains[-1], "tip_link", "") or "")
return ""
def _end_effector_link(end_effector: Any, groups_by_name: dict[str, Any]) -> str:
group_name = str(getattr(end_effector, "group", "") or "")
group = groups_by_name.get(group_name)
if group is not None:
group_tip = _group_tip_link(group)
if group_tip:
return group_tip
return ""
def _srdf_inventory_from_source(srdf_source: SrdfSource, *, srdf_path: Path, urdf_path: Path) -> dict[str, Any]:
groups_by_name = {group.name: group for group in srdf_source.planning_groups}
return {
"urdf": str(urdf_path),
"srdf": str(srdf_path),
"robotName": srdf_source.robot_name,
"planningGroups": [
{
"name": group.name,
"jointNames": list(group.joint_names),
"linkNames": list(group.link_names),
"chains": [
{"baseLink": chain.base_link, "tipLink": chain.tip_link}
for chain in group.chains
],
"subgroups": list(group.subgroups),
}
for group in srdf_source.planning_groups
],
"endEffectors": [
{
"name": end_effector.name,
"link": _end_effector_link(end_effector, groups_by_name) or end_effector.parent_link,
"parentLink": end_effector.parent_link,
"group": end_effector.group,
"parentGroup": end_effector.parent_group,
}
for end_effector in srdf_source.end_effectors
],
"groupStates": [
{
"name": group_state.name,
"group": group_state.group,
"jointValuesByName": group_state.joint_values_by_name,
"jointValuesByNameRad": group_state.joint_values_by_name,
}
for group_state in srdf_source.group_states
],
"disabledCollisionPairs": [
{
"link1": pair.link1,
"link2": pair.link2,
"reason": pair.reason,
"source": pair.source,
}
for pair in srdf_source.disabled_collision_pairs
],
}
def _validate_planning_groups(value: Any, *, link_names: set[str], joint_names: set[str]) -> list[dict[str, Any]]:
if not isinstance(value, list) or not value:
raise MotionProtocolError("SRDF planningGroups must be a non-empty array")
groups: list[dict[str, Any]] = []
seen = set()
for raw_group in value:
if not _plain_object(raw_group):
raise MotionProtocolError("SRDF planning group must be an object")
name = _string(raw_group.get("name"), label="SRDF planning group name")
if name in seen:
raise MotionProtocolError(f"Duplicate SRDF planning group {name}")
seen.add(name)
group_joint_names = _validate_names(raw_group.get("jointNames"), allowed=joint_names, label=f"SRDF planning group {name} jointNames")
group_link_names = _validate_names(raw_group.get("linkNames"), allowed=link_names, label=f"SRDF planning group {name} linkNames")
chains = raw_group.get("chains") if isinstance(raw_group.get("chains"), list) else []
normalized_chains: list[dict[str, str]] = []
for raw_chain in chains:
if not _plain_object(raw_chain):
raise MotionProtocolError(f"SRDF planning group {name} chains must contain objects")
base_link = _string(raw_chain.get("baseLink"), label=f"SRDF planning group {name} chain baseLink")
tip_link = _string(raw_chain.get("tipLink"), label=f"SRDF planning group {name} chain tipLink")
if base_link not in link_names or tip_link not in link_names:
raise MotionProtocolError(f"SRDF planning group {name} chain references missing URDF link")
normalized_chains.append({"baseLink": base_link, "tipLink": tip_link})
subgroups = [str(group).strip() for group in raw_group.get("subgroups", []) if str(group).strip()]
groups.append({
"name": name,
"jointNames": group_joint_names,
"linkNames": group_link_names,
"chains": normalized_chains,
"subgroups": subgroups,
})
group_names = {group["name"] for group in groups}
for group in groups:
for subgroup in group["subgroups"]:
if subgroup not in group_names:
raise MotionProtocolError(f"SRDF planning group {group['name']} references missing subgroup {subgroup}")
return groups
def _validate_names(value: Any, *, allowed: set[str], label: str) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError(f"{label} must be an array")
names: list[str] = []
seen = set()
for raw_name in value:
name = str(raw_name or "").strip()
if not name:
raise MotionProtocolError(f"{label} cannot include empty names")
if name in seen:
raise MotionProtocolError(f"{label} includes duplicate {name}")
if name not in allowed:
raise MotionProtocolError(f"{label} references missing name {name}")
seen.add(name)
names.append(name)
return names
def _link_names_for_group(
group: dict[str, Any],
*,
robot: dict[str, Any],
groups_by_name: dict[str, dict[str, Any]],
visited: set[str] | None = None,
) -> set[str]:
links = set(str(link) for link in group.get("linkNames", []) if str(link))
joints = robot.get("joints")
if not isinstance(joints, dict):
return links
def add_joint_links(joint_name: str) -> None:
joint = joints.get(joint_name)
if not isinstance(joint, dict):
return
child = str(joint.get("child") or "").strip()
if child:
links.add(child)
for joint_name in group.get("jointNames", []):
add_joint_links(str(joint_name))
for chain in group.get("chains", []):
if not isinstance(chain, dict):
continue
base_link = str(chain.get("baseLink") or "").strip()
tip_link = str(chain.get("tipLink") or "").strip()
if tip_link:
links.add(tip_link)
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
add_joint_links(joint_name)
if visited is None:
visited = set()
group_name = str(group.get("name") or "").strip()
if group_name:
visited.add(group_name)
for subgroup_name in group.get("subgroups", []):
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
links.update(_link_names_for_group(subgroup, robot=robot, groups_by_name=groups_by_name, visited=visited))
links.discard("")
return links
def _joint_adjacent_to_any_link(robot: dict[str, Any], parent_link: str, child_links: set[str]) -> bool:
joints = robot.get("joints")
if not isinstance(joints, dict):
return False
for joint in joints.values():
if not isinstance(joint, dict):
continue
parent = str(joint.get("parent") or "").strip()
child = str(joint.get("child") or "").strip()
if (parent == parent_link and child in child_links) or (child == parent_link and parent in child_links):
return True
return False
def _validate_end_effectors(
value: Any,
*,
link_names: set[str],
groups: dict[str, dict[str, Any]],
robot: dict[str, Any],
) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF endEffectors must be an array")
end_effectors: list[dict[str, Any]] = []
seen = set()
for raw_end_effector in value:
if not _plain_object(raw_end_effector):
raise MotionProtocolError("SRDF end effector must be an object")
name = _string(raw_end_effector.get("name"), label="SRDF end effector name")
if name in seen:
raise MotionProtocolError(f"Duplicate SRDF end effector {name}")
seen.add(name)
parent_link = _string(raw_end_effector.get("parentLink"), label=f"SRDF end effector {name} parentLink")
if parent_link not in link_names:
raise MotionProtocolError(f"SRDF end effector {name} references missing parentLink {parent_link}")
group_name = _string(raw_end_effector.get("group"), label=f"SRDF end effector {name} group")
if group_name not in groups:
raise MotionProtocolError(f"SRDF end effector {name} references missing group {group_name}")
parent_group = str(raw_end_effector.get("parentGroup") or "").strip()
if parent_group and parent_group not in groups:
raise MotionProtocolError(f"SRDF end effector {name} references missing parentGroup {parent_group}")
link = str(raw_end_effector.get("link") or _group_tip_link(groups[group_name]) or parent_link).strip()
if link not in link_names:
raise MotionProtocolError(f"SRDF end effector {name} references missing link {link}")
end_effector_links = _link_names_for_group(groups[group_name], robot=robot, groups_by_name=groups)
if parent_group:
parent_group_links = _link_names_for_group(groups[parent_group], robot=robot, groups_by_name=groups)
overlap = sorted(end_effector_links & parent_group_links)
if overlap:
raise MotionProtocolError(f"SRDF end effector {name} group shares link(s) with parentGroup: {overlap}")
if parent_link not in parent_group_links:
raise MotionProtocolError(
f"SRDF end effector {name} parentLink {parent_link} is not in parentGroup {parent_group}"
)
if end_effector_links and parent_link not in end_effector_links and not _joint_adjacent_to_any_link(
robot,
parent_link,
end_effector_links,
):
raise MotionProtocolError(f"SRDF end effector {name} parentLink is not adjacent to its group")
end_effectors.append({
"name": name,
"link": link,
"parentLink": parent_link,
"group": group_name,
"parentGroup": parent_group,
})
return end_effectors
def _validate_group_state_joint_value(state_name: str, joint_name: str, value: float, joint: dict[str, Any]) -> None:
joint_type = str(joint.get("type") or "").strip()
if joint_type == "fixed":
raise MotionProtocolError(f"SRDF group state {state_name} cannot set fixed joint {joint_name}")
if bool(joint.get("mimic")):
raise MotionProtocolError(f"SRDF group state {state_name} cannot set mimic joint {joint_name}")
if joint_type == "continuous":
return
lower = joint.get("lower")
upper = joint.get("upper")
if isinstance(lower, float) and value < lower:
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is below its URDF lower limit")
if isinstance(upper, float) and value > upper:
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is above its URDF upper limit")
def _validate_group_states(value: Any, *, groups: dict[str, dict[str, Any]], robot: dict[str, Any]) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF groupStates must be an array")
states: list[dict[str, Any]] = []
seen_keys: set[str] = set()
joint_payload = robot.get("joints")
if not isinstance(joint_payload, dict):
raise MotionProtocolError("URDF robot inventory is invalid")
for raw_state in value:
if not _plain_object(raw_state):
raise MotionProtocolError("SRDF group state must be an object")
name = _string(raw_state.get("name"), label="SRDF group state name")
group = _string(raw_state.get("group"), label=f"SRDF group state {name} group")
state_key = f"{group}/{name}"
if state_key in seen_keys:
raise MotionProtocolError(f"Duplicate SRDF group state {state_key}")
seen_keys.add(state_key)
if group not in groups:
raise MotionProtocolError(f"SRDF group state {name} references missing group {group}")
group_joint_names = set(_joint_names_for_group(groups[group], robot=robot, groups_by_name=groups))
joint_values = raw_state.get("jointValuesByName")
legacy_joint_values = raw_state.get("jointValuesByNameRad")
if joint_values is None and legacy_joint_values is not None:
joint_values = legacy_joint_values
if not _plain_object(joint_values):
raise MotionProtocolError(f"SRDF group state {name} jointValuesByName must be an object")
parsed_values: dict[str, float] = {}
for raw_joint_name, raw_value in joint_values.items():
joint_name = str(raw_joint_name or "").strip()
joint = joint_payload.get(joint_name)
if not isinstance(joint, dict):
raise MotionProtocolError(f"SRDF group state {name} references missing joint {joint_name}")
if joint_name not in group_joint_names:
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} is not in group {group}")
try:
parsed_value = float(raw_value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be numeric") from exc
if not isfinite(parsed_value):
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be finite")
_validate_group_state_joint_value(name, joint_name, parsed_value, joint)
parsed_values[joint_name] = parsed_value
states.append({"name": name, "group": group, "jointValuesByName": parsed_values, "jointValuesByNameRad": parsed_values})
return states
def _validate_disabled_pairs(value: Any, *, link_names: set[str]) -> list[dict[str, str]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF disabledCollisionPairs must be an array")
pairs: list[dict[str, str]] = []
seen: set[str] = set()
for raw_pair in value:
if not _plain_object(raw_pair):
raise MotionProtocolError("SRDF disabled collision pair must be an object")
link1 = _string(raw_pair.get("link1"), label="SRDF disabled collision link1")
link2 = _string(raw_pair.get("link2"), label="SRDF disabled collision link2")
if link1 == link2:
raise MotionProtocolError("SRDF disabled collision pair cannot repeat the same link")
if link1 not in link_names or link2 not in link_names:
raise MotionProtocolError("SRDF disabled collision pair references missing URDF link")
pair_key = "/".join(sorted((link1, link2)))
if pair_key in seen:
raise MotionProtocolError(f"Duplicate SRDF disabled collision pair {pair_key}")
seen.add(pair_key)
reason = str(raw_pair.get("reason") or "").strip()
if not reason:
raise MotionProtocolError(f"SRDF disabled collision pair {pair_key} requires a reason")
pairs.append({
"link1": link1,
"link2": link2,
"reason": reason,
"source": str(raw_pair.get("source") or "manual").strip(),
})
return pairs
def _moveit_settings(payload: Any) -> dict[str, Any]:
if not _plain_object(payload):
return {}
return _optional_object(payload.get("moveit2"), label="moveit2")
def _target_payload(payload: Any) -> dict[str, Any]:
if not _plain_object(payload):
return {}
return _optional_object(payload.get("target"), label="target")
def _find_group(metadata: dict[str, Any], name: str) -> dict[str, Any]:
for group in metadata.get("planningGroups", []):
if str(group.get("name") or "") == name:
return group
raise MotionProtocolError(f"Selected planning group {name} is not defined by the SRDF")
def _find_end_effector(metadata: dict[str, Any], name: str) -> dict[str, Any]:
for end_effector in metadata.get("endEffectors", []):
if str(end_effector.get("name") or "") == name:
return end_effector
raise MotionProtocolError(f"Selected end effector {name} is not defined by the SRDF")
def _default_end_effector(metadata: dict[str, Any], planning_group_name: str) -> dict[str, Any] | None:
end_effectors = list(metadata.get("endEffectors", []))
for end_effector in end_effectors:
if str(end_effector.get("parentGroup") or "") == planning_group_name:
return end_effector
return end_effectors[0] if end_effectors else None
def _build_command(
*,
command_name: str,
payload: Any,
metadata: dict[str, Any],
robot: dict[str, Any],
) -> dict[str, Any]:
moveit2 = _moveit_settings(payload)
target = _target_payload(payload)
default_group = metadata["planningGroups"][0]
planning_group_name = _string(
moveit2.get("planningGroup"),
label="moveit2.planningGroup",
default=str(default_group.get("name") or ""),
)
planning_group = _find_group(metadata, planning_group_name)
default_end_effector = _default_end_effector(metadata, planning_group_name)
selected_end_effector_name = str(moveit2.get("endEffector") or target.get("endEffector") or "").strip()
selected_end_effector = _find_end_effector(metadata, selected_end_effector_name) if selected_end_effector_name else default_end_effector
if selected_end_effector is None:
raise MotionProtocolError("SRDF must define an end effector for MoveIt2 pose requests")
robot_links = robot.get("links")
if not isinstance(robot_links, set):
raise MotionProtocolError("URDF robot inventory is invalid")
target_frame = _string(
moveit2.get("targetFrame") or target.get("frame"),
label="moveit2.targetFrame",
default=next(iter(sorted(robot_links)), ""),
)
if target_frame not in robot_links:
raise MotionProtocolError(f"moveit2.targetFrame references missing URDF link {target_frame}")
ik = _optional_object(moveit2.get("ik"), label="moveit2.ik")
planning = _optional_object(moveit2.get("planning"), label="moveit2.planning")
target_has_orientation = "quat_xyzw" in target or "rpy" in target
position_only_ik = _boolean(ik.get("positionOnly"), default=not target_has_orientation)
if not target_has_orientation and not position_only_ik:
raise MotionProtocolError("target orientation is required when moveit2.ik.positionOnly is false")
groups_by_name = {
str(group.get("name") or ""): group
for group in metadata.get("planningGroups", [])
if isinstance(group, dict) and str(group.get("name") or "")
}
joint_names = _joint_names_for_group(planning_group, robot=robot, groups_by_name=groups_by_name)
target_link = _string(
moveit2.get("targetLink") or target.get("targetLink") or target.get("link"),
label="moveit2.targetLink",
default=str(selected_end_effector.get("link") or ""),
)
if target_link not in robot_links:
raise MotionProtocolError(f"moveit2.targetLink references missing URDF link {target_link}")
command = {
"planningGroup": planning_group_name,
"jointNames": joint_names,
"endEffectors": [
{
"name": selected_end_effector["name"],
"link": target_link,
"inferredLink": selected_end_effector["link"],
"frame": target_frame,
"planningGroup": planning_group_name,
"jointNames": joint_names,
"positionTolerance": _positive_float(
ik.get("tolerance", moveit2.get("positionTolerance")),
label="moveit2.ik.tolerance",
default=DEFAULT_POSITION_TOLERANCE,
),
}
],
"ik": {
"timeout": _positive_float(ik.get("timeout"), label="moveit2.ik.timeout", default=DEFAULT_IK_TIMEOUT),
"attempts": _positive_int(ik.get("attempts"), label="moveit2.ik.attempts", default=DEFAULT_IK_ATTEMPTS),
"positionOnly": position_only_ik,
"tolerance": _positive_float(
ik.get("tolerance", moveit2.get("positionTolerance")),
label="moveit2.ik.tolerance",
default=DEFAULT_POSITION_TOLERANCE,
),
},
"planner": {
"pipeline": _string(planning.get("pipeline"), label="moveit2.planning.pipeline", default=DEFAULT_PLANNING_PIPELINE),
"plannerId": _string(planning.get("plannerId"), label="moveit2.planning.plannerId", default=DEFAULT_PLANNER_ID),
"planningTime": _positive_float(planning.get("planningTime"), label="moveit2.planning.planningTime", default=DEFAULT_PLANNING_TIME),
"maxVelocityScalingFactor": _scaling(
planning.get("maxVelocityScalingFactor"),
label="moveit2.planning.maxVelocityScalingFactor",
default=1.0,
),
"maxAccelerationScalingFactor": _scaling(
planning.get("maxAccelerationScalingFactor"),
label="moveit2.planning.maxAccelerationScalingFactor",
default=1.0,
),
},
}
if command_name not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {command_name}")
return command
def build_moveit2_context(
*,
repo_root: str | Path,
dir: Any = DEFAULT_CAD_DIRECTORY,
file: Any,
type: Any,
payload: Any = None,
) -> dict[str, Any]:
command_name = str(type or "").strip()
if command_name not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {command_name or '(missing)'}")
resolved_repo_root = Path(repo_root).resolve()
cad_dir = normalize_cad_directory(dir)
file_ref = normalize_file_ref(file)
cad_root = (resolved_repo_root / cad_dir).resolve()
if not _path_is_inside(cad_root, resolved_repo_root):
raise MotionProtocolError(f"dir must stay inside the repository: {cad_dir}")
file_ref = _file_ref_relative_to_cad_dir(file_ref, cad_dir=cad_dir, cad_root=cad_root)
srdf_path = (cad_root / file_ref).resolve()
if not _path_is_inside(srdf_path, cad_root):
root_label = cad_dir or "the repository"
raise MotionProtocolError(f"file must stay inside {root_label}: {file_ref}")
if not srdf_path.is_file():
raise MotionProtocolError(f"SRDF file does not exist: {file_ref}")
try:
srdf_source = read_srdf_source(srdf_path)
except Exception as exc:
raise MotionProtocolError(str(exc)) from exc
urdf_path = _resolve_srdf_urdf_path(srdf_source, srdf_path=srdf_path, repo_root=resolved_repo_root)
robot = _urdf_robot(urdf_path)
inventory_payload = _srdf_inventory_from_source(srdf_source, srdf_path=srdf_path, urdf_path=urdf_path)
srdf_inventory = _validate_srdf_inventory(inventory_payload, robot=robot)
command = _build_command(command_name=command_name, payload=payload, metadata=srdf_inventory, robot=robot)
version_paths = [urdf_path, srdf_path]
return {
"repoRoot": str(resolved_repo_root),
"dir": cad_dir,
"file": file_ref,
"urdfPath": str(urdf_path),
"srdfPath": str(srdf_path),
"modelAssetHash": _combined_version(version_paths),
"commandName": command_name,
"command": command,
"srdf": srdf_inventory,
}
@@ -0,0 +1,29 @@
from __future__ import annotations
from functools import lru_cache
from typing import Any
from moveit2_server.protocol import MotionProtocolError, MotionRequest
from moveit2_server.moveit_py import MoveItPyAdapter
@lru_cache(maxsize=1)
def _adapter() -> MoveItPyAdapter:
return MoveItPyAdapter()
def dispatch(request: MotionRequest) -> dict[str, Any]:
adapter = _adapter()
if request.type == "srdf.solvePose":
result = adapter.solve_pose(request)
elif request.type == "srdf.planToPose":
result = adapter.plan_to_pose(request)
else:
raise MotionProtocolError(f"Unsupported request type {request.type}")
if not isinstance(result, dict):
raise MotionProtocolError("MoveIt2 adapter result must be an object")
if result.get("ok") is False:
raise RuntimeError(str(result.get("message") or "MoveIt2 request failed"))
result.pop("ok", None)
return result
@@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
import json
import time
from typing import Any
def _json_list(value: str, label: str) -> list[Any]:
payload = json.loads(value)
if not isinstance(payload, list):
raise ValueError(f"{label} must be a JSON array")
return payload
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m moveit2_server.joint_state_seed")
parser.add_argument("--names", required=True, help="JSON array of joint names")
parser.add_argument("--positions", required=True, help="JSON array of joint positions in ROS units")
parser.add_argument("--duration", type=float, default=8.0)
parser.add_argument("--rate", type=float, default=30.0)
args = parser.parse_args(argv)
names = [str(name) for name in _json_list(args.names, "names")]
positions = [float(position) for position in _json_list(args.positions, "positions")]
if len(names) != len(positions):
raise ValueError("names and positions must have the same length")
if args.duration <= 0 or args.rate <= 0:
raise ValueError("duration and rate must be positive")
import rclpy
from sensor_msgs.msg import JointState
rclpy.init(args=None)
node = rclpy.create_node("moveit2_server_joint_state_seed")
publisher = node.create_publisher(JointState, "/joint_states", 10)
deadline = time.monotonic() + args.duration
period = 1.0 / args.rate
try:
while time.monotonic() < deadline and rclpy.ok():
message = JointState()
message.header.stamp = node.get_clock().now().to_msg()
message.name = names
message.position = positions
publisher.publish(message)
rclpy.spin_once(node, timeout_sec=0.0)
time.sleep(period)
finally:
try:
node.destroy_node()
except Exception:
pass
try:
rclpy.shutdown()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,589 @@
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
import subprocess
import sys
from typing import Any
import xml.etree.ElementTree as ET
from moveit2_server.protocol import (
MotionProtocolError,
normalize_joint_values,
normalize_motion_target,
)
def _deg_to_rad(value: float) -> float:
return (float(value) * math.pi) / 180.0
def _rad_to_deg(value: float) -> float:
return (float(value) * 180.0) / math.pi
def _solve_command(request: Any) -> dict[str, Any]:
return request.command
def _target_end_effector_name(request: Any) -> str:
target = request.payload.get("target") if isinstance(request.payload, dict) else None
if not isinstance(target, dict):
return ""
return str(target.get("endEffector") or "").strip()
def _selected_end_effector(request: Any) -> dict[str, Any]:
command = _solve_command(request)
end_effectors = command.get("endEffectors", [])
target_name = _target_end_effector_name(request)
if target_name:
for end_effector in end_effectors:
if str(end_effector.get("name", "")).strip() == target_name:
return end_effector
raise MotionProtocolError(f"Unknown end effector {target_name}")
for end_effector in end_effectors:
return end_effector
raise MotionProtocolError("moveit_py command endEffectors must be non-empty")
def _joint_names(request: Any) -> list[str]:
command = _solve_command(request)
end_effector = _selected_end_effector(request)
joint_names = [
str(name).strip()
for name in end_effector.get("jointNames", command.get("jointNames", []))
if str(name).strip()
]
if not joint_names:
raise MotionProtocolError("moveit_py command jointNames must be non-empty")
return joint_names
def _end_effector(request: Any, name: str) -> dict[str, Any]:
end_effector = _selected_end_effector(request)
if str(end_effector.get("name", "")).strip() == name:
return end_effector
raise MotionProtocolError(f"Unknown end effector {name}")
def _planning_group(request: Any) -> str:
command = _solve_command(request)
end_effector = _selected_end_effector(request)
planning_group = str(
end_effector.get("planningGroup")
or command.get("planningGroup")
or request.command.get("planningGroup")
or ""
).strip()
if not planning_group:
raise MotionProtocolError("moveit_py command planningGroup must be non-empty")
return planning_group
def _robot_description_for_moveit(urdf_path: Path) -> str:
"""Return URDF XML with relative mesh paths rewritten as file URIs."""
try:
tree = ET.parse(urdf_path)
except ET.ParseError:
return urdf_path.read_text(encoding="utf-8")
root = tree.getroot()
changed = False
for mesh in root.findall(".//mesh"):
filename = str(mesh.get("filename") or "").strip()
if not filename or "://" in filename or filename.startswith("package:"):
continue
path = Path(filename)
if not path.is_absolute():
path = urdf_path.parent / path
mesh.set("filename", path.resolve().as_uri())
changed = True
if not changed:
return urdf_path.read_text(encoding="utf-8")
return ET.tostring(root, encoding="unicode")
def _active_urdf_joints(urdf_path: Path) -> list[tuple[str, str]]:
try:
root = ET.parse(urdf_path).getroot()
except (OSError, ET.ParseError):
return []
joints: list[tuple[str, str]] = []
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
joint_type = str(joint.get("type") or "").strip()
if name and joint_type != "fixed":
joints.append((name, joint_type))
return joints
def _urdf_joint_types(urdf_path: Path) -> dict[str, str]:
try:
root = ET.parse(urdf_path).getroot()
except (OSError, ET.ParseError):
return {}
joint_types: dict[str, str] = {}
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
joint_type = str(joint.get("type") or "").strip()
if name:
joint_types[name] = joint_type
return joint_types
def _joint_types_for_request(request: Any) -> dict[str, str]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
joint_types = _urdf_joint_types(urdf_path)
if joint_types:
return joint_types
command = _solve_command(request)
fallback_names = [str(name).strip() for name in command.get("jointNames", []) if str(name).strip()]
if not fallback_names:
fallback_names = _joint_names(request)
return {name: "revolute" for name in fallback_names}
def _native_start_joint_values(request: Any, joint_types: dict[str, str] | None = None) -> dict[str, float]:
joint_types = joint_types or _joint_types_for_request(request)
payload = request.payload if isinstance(request.payload, dict) else {}
if "startJointValuesByName" in payload:
return normalize_joint_values(payload.get("startJointValuesByName"), label="startJointValuesByName")
legacy_values = normalize_joint_values(payload.get("startJointValuesByNameDeg"), label="startJointValuesByNameDeg")
return {
name: float(value) if str(joint_types.get(name) or "").strip() == "prismatic" else _deg_to_rad(value)
for name, value in legacy_values.items()
}
def _native_to_legacy_joint_value(value: float, joint_type: str) -> float:
return float(value) if str(joint_type or "").strip() == "prismatic" else _rad_to_deg(value)
def _native_to_legacy_joint_values(values: dict[str, float], joint_types: dict[str, str]) -> dict[str, float]:
return {
name: _native_to_legacy_joint_value(value, str(joint_types.get(name) or "revolute"))
for name, value in values.items()
}
def _moveit2_groups(request: Any) -> list[dict[str, Any]]:
srdf = request.context.get("srdf", {}) if isinstance(request.context, dict) else {}
groups = srdf.get("planningGroups", []) if isinstance(srdf, dict) else []
return [group for group in groups if isinstance(group, dict)]
def _supports_kdl_kinematics(group: dict[str, Any]) -> bool:
if group.get("subgroups"):
return False
return bool(group.get("jointNames") or group.get("chains"))
def _kinematics_config(request: Any) -> dict[str, Any]:
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
return {
str(group.get("name")): {
"kinematics_solver": "kdl_kinematics_plugin/KDLKinematicsPlugin",
"kinematics_solver_search_resolution": 0.005,
"kinematics_solver_timeout": float(ik.get("timeout") or 0.05),
"position_only_ik": bool(ik.get("positionOnly", True)),
}
for group in _moveit2_groups(request)
if str(group.get("name") or "").strip() and _supports_kdl_kinematics(group)
}
def _planning_pipeline_config(request: Any) -> dict[str, Any]:
planner = request.command.get("planner", {}) if isinstance(request.command, dict) else {}
pipeline = str(planner.get("pipeline") or "ompl")
planner_id = str(planner.get("plannerId") or "RRTConnectkConfigDefault")
planning_time = float(planner.get("planningTime") or 1.0)
def request_params() -> dict[str, Any]:
return {
"planning_attempts": int(request.command.get("ik", {}).get("attempts") or 1),
"planning_pipeline": pipeline,
"planner_id": planner_id,
"planning_time": planning_time,
"max_velocity_scaling_factor": float(planner.get("maxVelocityScalingFactor") or 1.0),
"max_acceleration_scaling_factor": float(planner.get("maxAccelerationScalingFactor") or 1.0),
}
return {
"planning_pipelines": {
"pipeline_names": [pipeline],
},
pipeline: {
"planning_plugin": "ompl_interface/OMPLPlanner",
"planning_plugins": ["ompl_interface/OMPLPlanner"],
"request_adapters": [
"default_planning_request_adapters/ResolveConstraintFrames",
"default_planning_request_adapters/ValidateWorkspaceBounds",
"default_planning_request_adapters/CheckStartStateBounds",
"default_planning_request_adapters/CheckStartStateCollision",
],
"start_state_max_bounds_error": 0.1,
},
"plan_request_params": request_params(),
"ompl_rrtc": {
"plan_request_params": request_params(),
},
}
def _joint_state_seed(request: Any) -> tuple[list[str], list[float]]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
joint_types = _joint_types_for_request(request)
joint_values = _native_start_joint_values(request, joint_types)
joints = _active_urdf_joints(urdf_path)
if not joints:
joints = [(name, "revolute") for name in _joint_names(request)]
names: list[str] = []
positions: list[float] = []
for name, joint_type in joints:
names.append(name)
del joint_type
positions.append(float(joint_values.get(name, 0.0)))
return names, positions
class _JointStateSeeder:
def __init__(self, request: Any, *, duration_sec: float = 8.0, rate_hz: float = 30.0) -> None:
self._request = request
self._duration_sec = duration_sec
self._rate_hz = rate_hz
self._process: subprocess.Popen[bytes] | None = None
def start(self) -> None:
names, positions = _joint_state_seed(self._request)
if not names:
return
self._process = subprocess.Popen(
[
sys.executable,
"-m",
"moveit2_server.joint_state_seed",
"--names",
json.dumps(names),
"--positions",
json.dumps(positions),
"--duration",
str(self._duration_sec),
"--rate",
str(self._rate_hz),
],
stdout=subprocess.DEVNULL,
)
def stop(self) -> None:
if not self._process or self._process.poll() is not None:
return
self._process.terminate()
try:
self._process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait(timeout=1.0)
class MoveItPyAdapter:
"""MoveIt 2 adapter used by the local ROS 2 MoveIt2 server."""
def __init__(self) -> None:
self._moveit_by_key: dict[str, Any] = {}
def _config_dict(self, request: Any) -> dict[str, Any]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
srdf_path = Path(str(request.context.get("srdfPath") or ""))
if not urdf_path.is_file():
raise RuntimeError(f"URDF file does not exist: {urdf_path}")
config: dict[str, Any] = {
"robot_description": _robot_description_for_moveit(urdf_path),
"robot_description_kinematics": _kinematics_config(request),
}
if srdf_path.is_file():
config["robot_description_semantic"] = srdf_path.read_text(encoding="utf-8")
config.update(_planning_pipeline_config(request))
config["planning_scene_monitor_options"] = {
"name": "planning_scene_monitor",
"robot_description": "robot_description",
"joint_state_topic": "/joint_states",
"attached_collision_object_topic": "/moveit_cpp/planning_scene_monitor",
"publish_planning_scene_topic": "/moveit_cpp/publish_planning_scene",
"monitored_planning_scene_topic": "/moveit_cpp/monitored_planning_scene",
"wait_for_initial_state_timeout": 5.0,
}
ompl_config = config.setdefault("ompl", {})
if isinstance(ompl_config, dict):
ompl_config.setdefault("planning_plugin", "ompl_interface/OMPLPlanner")
ompl_config.setdefault("planning_plugins", ["ompl_interface/OMPLPlanner"])
ompl_config.setdefault("request_adapters", [
"default_planning_request_adapters/ResolveConstraintFrames",
"default_planning_request_adapters/ValidateWorkspaceBounds",
"default_planning_request_adapters/CheckStartStateBounds",
"default_planning_request_adapters/CheckStartStateCollision",
])
ompl_config.setdefault("start_state_max_bounds_error", 0.1)
planning_scene_monitor_options = config.setdefault("planning_scene_monitor_options", {})
if isinstance(planning_scene_monitor_options, dict):
current_timeout = float(planning_scene_monitor_options.get("wait_for_initial_state_timeout") or 0.0)
planning_scene_monitor_options["wait_for_initial_state_timeout"] = max(current_timeout, 5.0)
return config
def _moveit(self, request: Any) -> Any:
try:
import rclpy
from moveit.planning import MoveItPy
except ImportError as exc:
raise RuntimeError(
"moveit_py adapter requires a ROS 2 environment with rclpy and moveit_py. "
f"Current interpreter: {sys.executable}. Start moveit2_server from a RoboStack/ROS shell."
) from exc
cache_key = str(request.context.get("modelAssetHash") or request.context.get("urdfPath") or "")
if cache_key in self._moveit_by_key:
return self._moveit_by_key[cache_key]
if not rclpy.ok():
rclpy.init(args=None)
node_hash = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:10] if cache_key else "default"
config = self._config_dict(request)
joint_state_seeder = _JointStateSeeder(request)
joint_state_seeder.start()
try:
try:
moveit = MoveItPy(
node_name=f"moveit2_server_{node_hash}",
config_dict=config,
)
except TypeError:
# Older bindings do not expose config_dict; they require equivalent
# ROS parameters to be supplied by the launch environment.
moveit = MoveItPy(node_name=f"moveit2_server_{node_hash}")
finally:
joint_state_seeder.stop()
self._moveit_by_key[cache_key] = moveit
return moveit
def _planning_component(self, request: Any, planning_group: str) -> Any:
return self._moveit(request).get_planning_component(planning_group)
def _robot_state(self, request: Any) -> Any:
try:
from moveit.core.robot_state import RobotState
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import RobotState for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
robot_state = RobotState(self._moveit(request).get_robot_model())
joint_values = _native_start_joint_values(request)
joint_names = _joint_names(request)
positions = [float(joint_values.get(name, 0.0)) for name in joint_names]
planning_group = _planning_group(request)
if hasattr(robot_state, "set_joint_group_positions"):
robot_state.set_joint_group_positions(planning_group, positions)
elif hasattr(robot_state, "set_joint_positions"):
robot_state.set_joint_positions(joint_names, positions)
else:
raise RuntimeError("moveit_py RobotState does not expose a supported joint setter")
return robot_state
def _pose_stamped(self, request: Any) -> tuple[Any, dict[str, Any]]:
try:
from geometry_msgs.msg import PoseStamped
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import geometry_msgs.msg.PoseStamped for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
target = normalize_motion_target(request.payload)
end_effector = _end_effector(request, target["endEffector"])
pose = PoseStamped()
pose.header.frame_id = target["frame"]
pose.pose.position.x = target["xyz"][0]
pose.pose.position.y = target["xyz"][1]
pose.pose.position.z = target["xyz"][2]
quat_xyzw = target.get("quat_xyzw") or (0.0, 0.0, 0.0, 1.0)
pose.pose.orientation.x = quat_xyzw[0]
pose.pose.orientation.y = quat_xyzw[1]
pose.pose.orientation.z = quat_xyzw[2]
pose.pose.orientation.w = quat_xyzw[3]
return pose, end_effector
def _position_only_ik(self, request: Any) -> bool:
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
return bool(ik.get("positionOnly", True))
def _position_goal_constraints(self, target_pose: Any, end_effector: dict[str, Any]) -> list[Any]:
try:
from geometry_msgs.msg import Pose
from moveit_msgs.msg import Constraints, PositionConstraint
from shape_msgs.msg import SolidPrimitive
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import MoveIt position constraint messages for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
sphere = SolidPrimitive()
sphere.type = SolidPrimitive.SPHERE
sphere.dimensions = [float(end_effector.get("positionTolerance") or 0.002)]
center = Pose()
center.orientation.w = 1.0
center.position.x = target_pose.pose.position.x
center.position.y = target_pose.pose.position.y
center.position.z = target_pose.pose.position.z
position_constraint = PositionConstraint()
position_constraint.header.frame_id = target_pose.header.frame_id
position_constraint.link_name = str(end_effector.get("link") or "")
position_constraint.constraint_region.primitives.append(sphere)
position_constraint.constraint_region.primitive_poses.append(center)
position_constraint.weight = 1.0
constraints = Constraints()
constraints.name = f"{position_constraint.link_name}_position_goal"
constraints.position_constraints.append(position_constraint)
return [constraints]
def _joint_values_from_state(self, robot_state: Any, request: Any) -> dict[str, float]:
joint_names = _joint_names(request)
planning_group = _planning_group(request)
if hasattr(robot_state, "get_joint_group_positions"):
values = robot_state.get_joint_group_positions(planning_group)
elif hasattr(robot_state, "get_joint_positions"):
values = robot_state.get_joint_positions(joint_names)
else:
raise RuntimeError("moveit_py RobotState does not expose a supported joint getter")
return {
joint_name: float(values[index])
for index, joint_name in enumerate(joint_names)
if index < len(values)
}
def solve_pose(self, request: Any) -> dict[str, Any]:
robot_state = self._robot_state(request)
target_pose, end_effector = self._pose_stamped(request)
planning_group = _planning_group(request)
if not hasattr(robot_state, "set_from_ik"):
raise RuntimeError("moveit_py RobotState does not expose set_from_ik")
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
timeout = float(ik.get("timeout") or 0.05)
attempts = int(ik.get("attempts") or 1)
try:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout, attempts)
except TypeError:
try:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout)
except TypeError:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"))
if not ok:
return {
"ok": False,
"message": "MoveIt did not find a pose solution",
}
joint_values = self._joint_values_from_state(robot_state, request)
joint_types = _joint_types_for_request(request)
return {
"jointValuesByName": joint_values,
"jointValuesByNameDeg": _native_to_legacy_joint_values(joint_values, joint_types),
"positionOnly": self._position_only_ik(request),
"residual": {"position": 0.0},
}
def _serialize_trajectory(self, trajectory: Any, joint_names: list[str], request: Any) -> dict[str, Any]:
if hasattr(trajectory, "get_robot_trajectory_msg"):
trajectory = trajectory.get_robot_trajectory_msg()
joint_trajectory = getattr(trajectory, "joint_trajectory", trajectory)
source_joint_names = list(getattr(joint_trajectory, "joint_names", []) or joint_names)
joint_types = _joint_types_for_request(request)
points = []
for point in getattr(joint_trajectory, "points", []) or []:
duration = getattr(point, "time_from_start", None)
seconds = float(getattr(duration, "sec", 0)) + (float(getattr(duration, "nanosec", 0)) / 1_000_000_000)
positions_by_name = {
name: float(point.positions[index])
for index, name in enumerate(source_joint_names)
if index < len(getattr(point, "positions", []))
}
native_positions = [positions_by_name.get(name, 0.0) for name in joint_names]
legacy_positions = [
_native_to_legacy_joint_value(positions_by_name.get(name, 0.0), str(joint_types.get(name) or "revolute"))
for name in joint_names
]
points.append({
"timeFromStartSec": seconds,
"positions": native_positions,
"positionsDeg": legacy_positions,
})
if len(points) > 1 and all(point["timeFromStartSec"] <= 0.0 for point in points):
total_joint_delta = 0.0
for previous, current in zip(points, points[1:], strict=False):
total_joint_delta += sum(
abs(current["positionsDeg"][index] - previous["positionsDeg"][index])
for index in range(min(len(previous["positionsDeg"]), len(current["positionsDeg"])))
)
total_duration = min(max(total_joint_delta / 240.0, 0.75), 4.0)
step_duration = total_duration / float(len(points) - 1)
for index, point in enumerate(points):
point["timeFromStartSec"] = index * step_duration
return {
"jointNames": joint_names,
"points": points,
}
def plan_to_pose(self, request: Any) -> dict[str, Any]:
target_pose, end_effector = self._pose_stamped(request)
planning_group = _planning_group(request)
planning_component = self._planning_component(request, planning_group)
start_state = self._robot_state(request)
planning_component.set_start_state(robot_state=start_state)
if self._position_only_ik(request):
try:
planning_component.set_goal_state(
motion_plan_constraints=self._position_goal_constraints(target_pose, end_effector),
)
except TypeError:
planning_component.set_goal_state(
pose_stamped_msg=target_pose,
pose_link=end_effector.get("link"),
)
else:
planning_component.set_goal_state(
pose_stamped_msg=target_pose,
pose_link=end_effector.get("link"),
)
plan_result = planning_component.plan()
if not plan_result:
return {
"ok": False,
"message": "MoveIt planning did not find a trajectory",
}
joint_names = _joint_names(request)
trajectory = self._serialize_trajectory(plan_result.trajectory, joint_names, request)
final_joint_values: dict[str, float] = {}
final_joint_values_legacy: dict[str, float] = {}
if trajectory["points"]:
final_joint_values = dict(zip(
trajectory["jointNames"],
trajectory["points"][-1]["positions"],
strict=False,
))
final_joint_values_legacy = dict(zip(
trajectory["jointNames"],
trajectory["points"][-1]["positionsDeg"],
strict=False,
))
return {
"jointValuesByName": final_joint_values,
"jointValuesByNameDeg": final_joint_values_legacy,
"trajectory": trajectory,
"positionOnly": self._position_only_ik(request),
"residual": {"position": 0.0},
}
@@ -0,0 +1,222 @@
from __future__ import annotations
from dataclasses import dataclass
import math
import re
from typing import Any
SUPPORTED_REQUEST_TYPES = {"srdf.solvePose", "srdf.planToPose"}
SUPPORTED_PROTOCOL_VERSION = 1
_ABSOLUTE_PATH_RE = re.compile(r"(?<![\w.])/(?:[^\s'\"<>:,;(){}[\]]+/)+[^\s'\"<>:,;(){}[\]]+")
class MotionProtocolError(ValueError):
"""Raised when a MoveIt2 server request is malformed."""
def _plain_object(value: Any) -> bool:
return isinstance(value, dict)
def _string(value: Any, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise MotionProtocolError(f"{label} is required")
return normalized
def _number(value: Any, label: str) -> float:
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be a finite number") from exc
if numeric != numeric or numeric in (float("inf"), float("-inf")):
raise MotionProtocolError(f"{label} must be a finite number")
return numeric
def normalize_xyz(value: Any, label: str = "target.xyz") -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise MotionProtocolError(f"{label} must be a 3-number array")
return (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
)
def normalize_quat_xyzw(value: Any, label: str = "target.quat_xyzw") -> tuple[float, float, float, float]:
if not isinstance(value, list) or len(value) != 4:
raise MotionProtocolError(f"{label} must be a 4-number array")
quat = (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
_number(value[3], f"{label}[3]"),
)
norm = math.sqrt(sum(component * component for component in quat))
if norm <= 1e-12:
raise MotionProtocolError(f"{label} must not be zero length")
return tuple(component / norm for component in quat)
def normalize_rpy(value: Any, label: str = "target.rpy") -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise MotionProtocolError(f"{label} must be a 3-number array")
return (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
)
def _quat_xyzw_from_rpy(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]:
cy = math.cos(yaw * 0.5)
sy = math.sin(yaw * 0.5)
cp = math.cos(pitch * 0.5)
sp = math.sin(pitch * 0.5)
cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5)
return normalize_quat_xyzw([
(sr * cp * cy) - (cr * sp * sy),
(cr * sp * cy) + (sr * cp * sy),
(cr * cp * sy) - (sr * sp * cy),
(cr * cp * cy) + (sr * sp * sy),
])
def normalize_joint_values(value: Any, label: str = "startJointValuesByNameDeg") -> dict[str, float]:
if value is None:
return {}
if not _plain_object(value):
raise MotionProtocolError(f"{label} must be an object")
normalized: dict[str, float] = {}
for raw_name, raw_value in value.items():
name = str(raw_name or "").strip()
if not name:
raise MotionProtocolError(f"{label} cannot include empty joint names")
normalized[name] = _number(raw_value, f"{label}.{name}")
return normalized
def normalize_protocol_version(value: Any) -> int:
if value is None:
return SUPPORTED_PROTOCOL_VERSION
try:
version = int(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError("protocolVersion must be an integer") from exc
if version != SUPPORTED_PROTOCOL_VERSION:
raise MotionProtocolError(f"Unsupported protocolVersion {version}")
return version
@dataclass(frozen=True)
class WireMessage:
id: str
type: str
protocol_version: int
payload: dict[str, Any]
@dataclass(frozen=True)
class MotionRequest:
id: str
type: str
protocol_version: int
payload: dict[str, Any]
context: dict[str, Any]
command: dict[str, Any]
def normalize_wire_message(message: Any) -> WireMessage:
if not _plain_object(message):
raise MotionProtocolError("MoveIt2 request must be an object")
request_id = _string(message.get("id"), "id")
request_type = _string(message.get("type"), "type")
protocol_version = normalize_protocol_version(message.get("protocolVersion"))
if request_type not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {request_type}")
payload = message.get("payload")
if not _plain_object(payload):
raise MotionProtocolError("payload must be an object")
return WireMessage(
id=request_id,
type=request_type,
protocol_version=protocol_version,
payload=payload,
)
def normalize_request(message: Any, *, context: dict[str, Any]) -> MotionRequest:
wire = normalize_wire_message(message)
if not _plain_object(context):
raise MotionProtocolError("request context must be an object")
command = context.get("command")
if not _plain_object(command):
raise MotionProtocolError("request context command must be an object")
return MotionRequest(
id=wire.id,
type=wire.type,
protocol_version=wire.protocol_version,
payload=wire.payload,
context=context,
command=command,
)
def normalize_motion_target(payload: dict[str, Any]) -> dict[str, Any]:
target = payload.get("target")
if not _plain_object(target):
raise MotionProtocolError("target must be an object")
has_quat = "quat_xyzw" in target
has_rpy = "rpy" in target
if has_quat and has_rpy:
raise MotionProtocolError("target must include exactly one of quat_xyzw or rpy")
quat_xyzw: tuple[float, float, float, float] | None = None
orientation_mode = "position_only"
if has_quat:
quat_xyzw = normalize_quat_xyzw(target.get("quat_xyzw"))
orientation_mode = "quat_xyzw"
elif has_rpy:
quat_xyzw = _quat_xyzw_from_rpy(*normalize_rpy(target.get("rpy")))
orientation_mode = "rpy"
return {
"endEffector": _string(target.get("endEffector"), "target.endEffector"),
"frame": _string(target.get("frame"), "target.frame"),
"xyz": normalize_xyz(target.get("xyz")),
"quat_xyzw": quat_xyzw,
"orientationMode": orientation_mode,
}
def success_response(request_id: str, result: dict[str, Any]) -> dict[str, Any]:
return {
"id": request_id,
"ok": True,
"result": result,
}
def _sanitize_error_message(message: str) -> str:
def replace(match: re.Match[str]) -> str:
path = match.group(0)
basename = path.rstrip("/").rsplit("/", 1)[-1]
return f".../{basename}" if basename else "<path>"
return _ABSOLUTE_PATH_RE.sub(replace, message)
def error_response(request_id: str, error: BaseException, *, debug: bool = False) -> dict[str, Any]:
message = str(error)
if not debug:
message = _sanitize_error_message(message)
return {
"id": request_id,
"ok": False,
"error": {
"code": error.__class__.__name__,
"message": message,
},
}
@@ -0,0 +1,108 @@
from __future__ import annotations
import argparse
import asyncio
import importlib
import json
import os
from pathlib import Path
from typing import Any
from moveit2_server.context import DEFAULT_CAD_DIRECTORY, build_moveit2_context
from moveit2_server.dispatcher import dispatch
from moveit2_server.protocol import error_response, normalize_request, normalize_wire_message, success_response
def _json_dumps(message: dict[str, Any]) -> str:
return json.dumps(message, separators=(",", ":"))
async def handle_message(raw_message: str, *, repo_root: Path) -> str:
request_id = ""
debug_errors = os.environ.get("MOVEIT2_SERVER_DEBUG_ERRORS", "").strip().lower() in {"1", "true", "yes", "on"}
try:
message = json.loads(raw_message)
wire = normalize_wire_message(message)
request_id = wire.id
payload_dir = wire.payload.get("dir", DEFAULT_CAD_DIRECTORY)
context = build_moveit2_context(
repo_root=repo_root,
dir=payload_dir,
file=wire.payload.get("file"),
type=wire.type,
payload=wire.payload,
)
request = normalize_request(message, context=context)
return _json_dumps(success_response(request.id, dispatch(request)))
except Exception as exc:
return _json_dumps(error_response(request_id, exc, debug=debug_errors))
async def handle_client(websocket: Any, path: str | None = None, *, repo_root: Path) -> None:
async for raw_message in websocket:
await websocket.send(await handle_message(str(raw_message), repo_root=repo_root))
async def run_server(*, repo_root: Path, host: str, port: int) -> None:
try:
from websockets.asyncio.server import serve
except ImportError:
from websockets import serve
async with serve(lambda websocket, path=None: handle_client(websocket, path, repo_root=repo_root), host, port):
print(f"moveit2_server listening on ws://{host}:{port}/ws", flush=True)
await asyncio.Future()
def _check_import(module_name: str, attribute_path: str | None = None) -> str:
module = importlib.import_module(module_name)
current: Any = module
if attribute_path:
for attribute in attribute_path.split("."):
current = getattr(current, attribute)
return str(getattr(module, "__file__", "built-in"))
def check_environment(*, repo_root: Path) -> dict[str, Any]:
checks = {
"repoRoot": str(repo_root),
"rmwImplementation": os.environ.get("RMW_IMPLEMENTATION", ""),
"imports": {
"websockets": _check_import("websockets"),
"rclpy": _check_import("rclpy"),
"geometry_msgs.msg.PoseStamped": _check_import("geometry_msgs.msg", "PoseStamped"),
"moveit.planning.MoveItPy": _check_import("moveit.planning", "MoveItPy"),
},
}
return checks
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="moveit2_server")
parser.add_argument(
"--repo-root",
default=str(Path.cwd()),
help="Repository root that contains the CAD scan directory.",
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--check",
action="store_true",
help="Verify required Python, ROS 2, MoveIt, and websocket imports, then exit.",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_arg_parser().parse_args(argv)
repo_root = Path(args.repo_root).resolve()
if args.check:
print(json.dumps(check_environment(repo_root=repo_root), indent=2, sort_keys=True))
return 0
asyncio.run(run_server(repo_root=repo_root, host=args.host, port=args.port))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,261 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from pathlib import Path
from typing import Literal
import xml.etree.ElementTree as ET
SRDF_SUFFIX = ".srdf"
SRDF_METADATA_NAMESPACE = "https://text-to-cad.dev/srdf"
LEGACY_EXPLORER_NAMESPACE = "https://text-to-cad.dev/explorer"
class SrdfSourceError(ValueError):
pass
@dataclass(frozen=True)
class SrdfChain:
base_link: str
tip_link: str
@dataclass(frozen=True)
class SrdfPlanningGroup:
name: str
joint_names: tuple[str, ...]
link_names: tuple[str, ...]
chains: tuple[SrdfChain, ...]
subgroups: tuple[str, ...]
@dataclass(frozen=True)
class SrdfEndEffector:
name: str
parent_link: str
group: str
parent_group: str
@dataclass(frozen=True)
class SrdfGroupState:
name: str
group: str
joint_values_by_name: dict[str, float]
@property
def joint_values_by_name_rad(self) -> dict[str, float]:
return self.joint_values_by_name
@dataclass(frozen=True)
class SrdfDisabledCollisionPair:
link1: str
link2: str
reason: str
source: Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"] = "manual"
@dataclass(frozen=True)
class SrdfSource:
file_ref: str
source_path: Path
robot_name: str
urdf_ref: str
planning_groups: tuple[SrdfPlanningGroup, ...]
end_effectors: tuple[SrdfEndEffector, ...]
group_states: tuple[SrdfGroupState, ...]
disabled_collision_pairs: tuple[SrdfDisabledCollisionPair, ...]
def read_srdf_source(srdf_path: Path) -> SrdfSource:
resolved_path = srdf_path.resolve()
if resolved_path.suffix.lower() != SRDF_SUFFIX:
raise SrdfSourceError(f"{resolved_path} is not an SRDF source file")
try:
root = ET.fromstring(resolved_path.read_text(encoding="utf-8"))
except (OSError, ET.ParseError) as exc:
raise SrdfSourceError(f"{_relative_to_repo(resolved_path)} could not be parsed as SRDF XML") from exc
return parse_srdf_root(root, source_path=resolved_path)
def parse_srdf_root(root: ET.Element, *, source_path: Path) -> SrdfSource:
if root.tag != "robot":
raise SrdfSourceError(f"{_relative_to_repo(source_path)} root element must be <robot>")
robot_name = str(root.attrib.get("name") or "").strip()
if not robot_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} robot name is required")
planning_groups: list[SrdfPlanningGroup] = []
group_names: list[str] = []
for group_element in root.findall("group"):
name = str(group_element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group name is required")
group_names.append(name)
planning_groups.append(
SrdfPlanningGroup(
name=name,
joint_names=_child_names(group_element, "joint"),
link_names=_child_names(group_element, "link"),
chains=tuple(
SrdfChain(
base_link=str(chain.attrib.get("base_link") or "").strip(),
tip_link=str(chain.attrib.get("tip_link") or "").strip(),
)
for chain in group_element.findall("chain")
),
subgroups=_child_names(group_element, "group"),
)
)
_raise_on_duplicates(group_names, source_path=source_path, label="group")
end_effectors: list[SrdfEndEffector] = []
end_effector_names: list[str] = []
for element in root.findall("end_effector"):
name = str(element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} end_effector name is required")
end_effector_names.append(name)
end_effectors.append(
SrdfEndEffector(
name=name,
parent_link=str(element.attrib.get("parent_link") or "").strip(),
group=str(element.attrib.get("group") or "").strip(),
parent_group=str(element.attrib.get("parent_group") or "").strip(),
)
)
_raise_on_duplicates(end_effector_names, source_path=source_path, label="end_effector")
group_states: list[SrdfGroupState] = []
group_state_keys: list[str] = []
for element in root.findall("group_state"):
name = str(element.attrib.get("name") or "").strip()
group = str(element.attrib.get("group") or "").strip()
if not name or not group:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state requires name and group")
group_state_keys.append(f"{group}/{name}")
joint_values: dict[str, float] = {}
joint_names: list[str] = []
for joint_element in element.findall("joint"):
joint_name = str(joint_element.attrib.get("name") or "").strip()
if not joint_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state joint name is required")
joint_names.append(joint_name)
try:
joint_value = float(joint_element.attrib.get("value"))
except (TypeError, ValueError) as exc:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be numeric"
) from exc
if not isfinite(joint_value):
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be finite"
)
joint_values[joint_name] = joint_value
_raise_on_duplicates(joint_names, source_path=source_path, label=f"group_state {name!r} joint")
group_states.append(SrdfGroupState(name=name, group=group, joint_values_by_name=joint_values))
_raise_on_duplicates(group_state_keys, source_path=source_path, label="group_state")
disabled_pairs: list[SrdfDisabledCollisionPair] = []
pair_keys: list[str] = []
for element in root.findall("disable_collisions"):
link1 = str(element.attrib.get("link1") or "").strip()
link2 = str(element.attrib.get("link2") or "").strip()
if not link1 or not link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions requires link1 and link2")
if link1 == link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions cannot repeat the same link")
reason = str(element.attrib.get("reason") or "").strip()
if not reason:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} disable_collisions for {link1!r}/{link2!r} requires a reason"
)
pair_key = "/".join(sorted((link1, link2)))
pair_keys.append(pair_key)
disabled_pairs.append(
SrdfDisabledCollisionPair(
link1=link1,
link2=link2,
reason=reason,
source=_disabled_collision_source(reason),
)
)
_raise_on_duplicates(pair_keys, source_path=source_path, label="disable_collisions")
return SrdfSource(
file_ref=_relative_to_repo(source_path.resolve()),
source_path=source_path.resolve(),
robot_name=robot_name,
urdf_ref=_linked_urdf_ref(root),
planning_groups=tuple(planning_groups),
end_effectors=tuple(end_effectors),
group_states=tuple(group_states),
disabled_collision_pairs=tuple(disabled_pairs),
)
def _local_name(tag: str) -> str:
return str(tag or "").rsplit("}", 1)[-1].split(":", 1)[-1]
def _linked_urdf_ref(root: ET.Element) -> str:
for child in list(root):
if _local_name(child.tag) != "urdf":
continue
tag = str(child.tag or "")
if (
tag.startswith(f"{{{SRDF_METADATA_NAMESPACE}}}")
or tag.startswith(f"{{{LEGACY_EXPLORER_NAMESPACE}}}")
or tag.startswith("tcad:")
or tag.startswith("explorer:")
):
return str(child.attrib.get("path") or "").strip()
return ""
def _child_names(element: ET.Element, tag: str) -> tuple[str, ...]:
names: list[str] = []
for child in element.findall(tag):
name = str(child.attrib.get("name") or "").strip()
if name:
names.append(name)
return tuple(names)
def _raise_on_duplicates(names: list[str], *, source_path: Path, label: str) -> None:
seen = set()
duplicates = []
for name in names:
if name in seen:
duplicates.append(name)
seen.add(name)
if duplicates:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} has duplicate {label} name(s): {sorted(set(duplicates))!r}"
)
def _disabled_collision_source(
reason: str,
) -> Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"]:
normalized = str(reason or "").strip().lower()
if "adjacent" in normalized:
return "adjacent"
if any(token in normalized for token in ("never", "always", "sample", "default")):
return "sampled"
if "setup" in normalized or "assistant" in normalized:
return "setup_assistant"
if "assum" in normalized:
return "assumed"
return "manual"
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return resolved.as_posix()
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "moveit2_server"
version = "0.3.9"
description = "Local MoveIt2 websocket server for SRDF solve and planning requests."
requires-python = ">=3.12"
dependencies = [
"websockets>=12",
]
[project.scripts]
moveit2_server = "moveit2_server.server:main"
[tool.setuptools]
packages = ["moveit2_server"]
[tool.setuptools.package-dir]
moveit2_server = "moveit2_server"
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=conda-common.sh
source "${SCRIPT_DIR}/conda-common.sh"
REPO_ROOT="$(find_repo_root)"
CONDA="$(find_conda)"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
exec "${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" \
python -m moveit2_server.server \
--repo-root "${REPO_ROOT}" \
--host "${MOVEIT2_SERVER_HOST:-127.0.0.1}" \
--port "${MOVEIT2_SERVER_PORT:-8765}" \
"$@"
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=conda-common.sh
source "${SCRIPT_DIR}/conda-common.sh"
REPO_ROOT="$(find_repo_root)"
CONDA="$(find_conda)"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
"${CONDA}" env update --file "${SCRIPT_DIR}/environment.yml" --prune
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m pip install -e "${SCRIPT_DIR}"
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m moveit2_server.server --check --repo-root "${REPO_ROOT}"
@@ -0,0 +1,13 @@
{
"name": "cad-viewer-runtime",
"private": true,
"type": "module",
"version": "0.3.9",
"scripts": {
"serve": "node backend/server.mjs",
"start": "node backend/server.mjs",
"moveit2:setup": "moveit2_server/setup.sh",
"moveit2:check": "moveit2_server/check-moveit2-server.sh",
"moveit2:serve": "moveit2_server/run-moveit2-server.sh"
}
}
@@ -0,0 +1,28 @@
{
"name": "cadjs",
"version": "0.3.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cadjs",
"version": "0.3.9",
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
},
"node_modules/gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
"license": "MIT"
},
"node_modules/three": {
"version": "0.160.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
"license": "MIT"
}
}
}
@@ -0,0 +1,23 @@
{
"name": "cadjs",
"version": "0.3.9",
"private": true,
"type": "module",
"description": "Reusable CAD render/viewer runtime for docs, CAD Viewer, and generated snapshot browser assets.",
"exports": {
".": "./src/index.js",
"./common/*": "./src/common/*",
"./lib/*": "./src/lib/*"
},
"scripts": {
"test": "node scripts/run-tests.mjs"
},
"sideEffects": [
"./src/common/headlessRenderEntry.js",
"./src/common/render.html"
],
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
}
@@ -0,0 +1,48 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(packageRoot, "..", "..");
const sourceRoot = path.join(packageRoot, "src");
function collectTests(dir, tests = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectTests(entryPath, tests);
} else if (/\.test\.[cm]?js$/u.test(entry.name)) {
tests.push(entryPath);
}
}
return tests;
}
const requestedTests = process.argv.slice(2).map((testPath) => path.resolve(packageRoot, testPath));
const tests = (requestedTests.length ? requestedTests : collectTests(sourceRoot)).sort();
if (!tests.length) {
console.error("No cadjs tests found.");
process.exit(1);
}
const result = spawnSync(process.execPath, [
"--test",
"--experimental-default-type=module",
...tests,
], {
cwd: packageRoot,
env: {
...process.env,
...(fs.existsSync(path.join(repoRoot, ".venv", "bin", "python"))
? { CAD_PYTHON: path.join(repoRoot, ".venv", "bin", "python") }
: {}),
...(fs.existsSync(path.join(repoRoot, "packages", "cadpy", "src"))
? { CAD_PYTHONPATH: path.join(repoRoot, "packages", "cadpy", "src") }
: {}),
},
stdio: "inherit",
});
process.exit(result.status ?? 1);
@@ -0,0 +1,337 @@
import * as THREE from "three";
export const WORLD_UP = Object.freeze([0, 0, 1]);
export const TOP_VIEW_UP = Object.freeze([0, 1, 0]);
export const RENDER_CAMERA_PRESETS = Object.freeze({
front: Object.freeze({ name: "front", direction: Object.freeze([0, -1, 0]), up: WORLD_UP }),
back: Object.freeze({ name: "back", direction: Object.freeze([0, 1, 0]), up: WORLD_UP }),
right: Object.freeze({ name: "right", direction: Object.freeze([1, 0, 0]), up: WORLD_UP }),
left: Object.freeze({ name: "left", direction: Object.freeze([-1, 0, 0]), up: WORLD_UP }),
top: Object.freeze({ name: "top", direction: Object.freeze([0, 0, 1]), up: TOP_VIEW_UP }),
bottom: Object.freeze({ name: "bottom", direction: Object.freeze([0, 0, -1]), up: TOP_VIEW_UP }),
iso: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
isometric: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
side: Object.freeze({ name: "side", direction: Object.freeze([1, 0, 0]), up: WORLD_UP })
});
export const RENDER_VIEW_PRESETS = RENDER_CAMERA_PRESETS;
const DEFAULT_CAMERA_PRESET = "iso";
const CAMERA_SPEC_KEYS = Object.freeze(["preset", "position", "target", "up", "zoom", "direction", "name"]);
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function normalizeVector(value, {
fieldName = "camera vector",
required = false,
nonZero = false,
strict = true
} = {}) {
if (value === undefined || value === null) {
if (required && strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
if (!Array.isArray(value) || value.length < 3) {
if (strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
const vector = [
Number(value[0]),
Number(value[1]),
Number(value[2])
];
if (!vector.every(Number.isFinite)) {
if (strict) {
throw new Error(`${fieldName} must contain only finite numbers`);
}
return null;
}
if (nonZero && vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2 <= 1e-12) {
if (strict) {
throw new Error(`${fieldName} must not be the zero vector`);
}
return null;
}
return vector;
}
export function cloneCameraVector(vector) {
return normalizeVector(vector, { strict: false });
}
export function normalizeCameraZoom(value, fallback = 1, {
fieldName = "camera.zoom",
strict = false
} = {}) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const numericValue = Number(value);
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue;
}
if (strict) {
throw new Error(`${fieldName} must be a positive finite number`);
}
return fallback;
}
function clonePreset(preset) {
return {
name: preset.name,
direction: [...preset.direction],
up: [...preset.up]
};
}
function cameraPresetByName(name, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const raw = String(name || DEFAULT_CAMERA_PRESET).trim().toLowerCase();
if (presets[raw]) {
return clonePreset(presets[raw]);
}
const parts = raw.split(":").map((entry) => Number(entry));
if (parts.length >= 2 && parts.every(Number.isFinite)) {
const azimuth = (parts[0] * Math.PI) / 180;
const elevation = (parts[1] * Math.PI) / 180;
const cosElevation = Math.cos(elevation);
return {
name: `${parts[0]}:${parts[1]}`,
direction: [
Math.sin(azimuth) * cosElevation,
-Math.cos(azimuth) * cosElevation,
Math.sin(elevation)
],
up: [...WORLD_UP]
};
}
if (strict) {
throw new Error(`Unknown camera preset: ${name}`);
}
return clonePreset(presets[DEFAULT_CAMERA_PRESET]);
}
function parseCameraJsonString(raw, { strict = true } = {}) {
const text = String(raw || "").trim();
if (!text.startsWith("{")) {
return null;
}
try {
const parsed = JSON.parse(text);
if (!isPlainObject(parsed)) {
throw new Error("camera JSON must be an object");
}
return parsed;
} catch (error) {
if (strict) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid camera JSON: ${message}`);
}
return null;
}
}
function validateCameraSpecKeys(spec) {
const unknownKeys = Object.keys(spec).filter((key) => !CAMERA_SPEC_KEYS.includes(key));
if (unknownKeys.length) {
throw new Error(`Unsupported camera fields: ${unknownKeys.join(", ")}`);
}
}
export function normalizeCameraSpec(rawCamera = DEFAULT_CAMERA_PRESET, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const parsedJson = typeof rawCamera === "string"
? parseCameraJsonString(rawCamera, { strict })
: null;
const source = parsedJson || rawCamera;
if (!isPlainObject(source)) {
const preset = cameraPresetByName(source || DEFAULT_CAMERA_PRESET, { presets, strict });
return {
sourceKind: "string",
name: preset.name,
preset: preset.name,
direction: preset.direction,
position: null,
target: null,
up: preset.up,
zoom: 1,
hasExplicitPosition: false,
hasExplicitTarget: false,
hasExplicitUp: false,
hasExplicitZoom: false
};
}
validateCameraSpecKeys(source);
const rawPreset = source.preset ?? DEFAULT_CAMERA_PRESET;
const preset = cameraPresetByName(rawPreset, { presets, strict });
const direction = normalizeVector(source.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || preset.direction;
const position = normalizeVector(source.position, {
fieldName: "camera.position",
strict
});
const target = normalizeVector(source.target, {
fieldName: "camera.target",
strict
});
const up = normalizeVector(source.up, {
fieldName: "camera.up",
nonZero: true,
strict
}) || preset.up || [...WORLD_UP];
const hasExplicitZoom = Object.prototype.hasOwnProperty.call(source, "zoom");
return {
sourceKind: "object",
name: String(source.name || preset.name || "custom"),
preset: preset.name,
direction,
position,
target,
up,
zoom: normalizeCameraZoom(source.zoom, 1, { strict, fieldName: "camera.zoom" }),
hasExplicitPosition: Object.prototype.hasOwnProperty.call(source, "position"),
hasExplicitTarget: Object.prototype.hasOwnProperty.call(source, "target"),
hasExplicitUp: Object.prototype.hasOwnProperty.call(source, "up"),
hasExplicitZoom
};
}
function settingsForCamera(sceneScale, settingsByScale) {
if (settingsByScale?.[sceneScale]) {
return settingsByScale[sceneScale];
}
if (settingsByScale?.cad) {
return settingsByScale.cad;
}
return {
minBoundsSpan: 1,
minModelRadius: 1,
minCameraDistance: 10,
minCameraFar: 1000
};
}
function centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale) {
const settings = settingsForCamera(sceneScale, settingsByScale);
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
const center = new THREE.Vector3(
(toFiniteNumber(min[0]) + toFiniteNumber(max[0], 1)) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1], 1)) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2], 1)) / 2
);
const size = new THREE.Vector3(
Math.max(toFiniteNumber(max[0], 1) - toFiniteNumber(min[0]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[1], 1) - toFiniteNumber(min[1]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[2], 1) - toFiniteNumber(min[2]), settings.minBoundsSpan)
);
return {
center,
radius: Math.max(size.length() / 2, settings.minModelRadius),
size
};
}
function vectorFromArray(vector) {
return new THREE.Vector3(vector[0], vector[1], vector[2]);
}
export function resolveCameraView(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
const target = spec.target ? vectorFromArray(spec.target) : null;
const position = spec.position ? vectorFromArray(spec.position) : null;
const direction = position && target
? position.clone().sub(target).normalize().toArray()
: normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict: false
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
return {
name: spec.name,
direction,
up: [...(spec.up || WORLD_UP)],
zoom: spec.zoom
};
}
export function resolveCameraSnapshot(rawCamera = DEFAULT_CAMERA_PRESET, bounds = null, {
sceneScale = "cad",
settingsByScale = null,
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const spec = normalizeCameraSpec(rawCamera, { presets, strict });
const settings = settingsForCamera(sceneScale, settingsByScale);
const { center, radius, size } = centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale);
const target = spec.target ? vectorFromArray(spec.target) : center.clone();
const direction = normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
const position = spec.position
? vectorFromArray(spec.position)
: target.clone().add(vectorFromArray(direction).normalize().multiplyScalar(
Math.max(radius * 3.2, settings.minCameraDistance || settings.minModelRadius * 10 || 10)
));
const resolvedDirection = position.clone().sub(target);
if (resolvedDirection.lengthSq() <= 1e-12) {
throw new Error("camera.position must differ from camera.target");
}
const up = vectorFromArray(spec.up || WORLD_UP);
if (up.lengthSq() <= 1e-12) {
throw new Error("camera.up must not be the zero vector");
}
return {
name: spec.name,
preset: spec.preset,
position: position.toArray(),
target: target.toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom,
direction: resolvedDirection.normalize().toArray(),
view: {
name: spec.name,
direction: resolvedDirection.normalize().toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom
},
sourceKind: spec.sourceKind,
hasExplicitPosition: spec.hasExplicitPosition,
hasExplicitTarget: spec.hasExplicitTarget,
hasExplicitUp: spec.hasExplicitUp,
hasExplicitZoom: spec.hasExplicitZoom,
radius,
size: size.toArray()
};
}
export function cameraSpecUsesPerspectiveProjection(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
return spec.sourceKind === "object" && (
spec.hasExplicitPosition ||
spec.hasExplicitTarget ||
spec.hasExplicitUp
);
}
@@ -0,0 +1,48 @@
import {
buildPartTransformMatrix
} from "./stepModuleEffects.js";
function applyObjectMatrix(THREE, object3d, matrix) {
if (!object3d || !(matrix instanceof THREE.Matrix4)) {
return;
}
object3d.matrixAutoUpdate = false;
const targetMatrix = object3d.matrix instanceof THREE.Matrix4 ? object3d.matrix : new THREE.Matrix4();
targetMatrix.copy(matrix);
object3d.matrix = targetMatrix;
object3d.matrixWorldNeedsUpdate = true;
}
export function composeDisplayRecordEffectMatrix(THREE, record) {
if (!record || !THREE?.Matrix4) {
return null;
}
const matrices = [
record.effectMatrix,
record.explodedViewMatrix
].filter((matrix) => matrix instanceof THREE.Matrix4);
if (!matrices.length) {
return null;
}
const combined = new THREE.Matrix4();
for (const matrix of matrices) {
combined.premultiply(matrix);
}
return combined;
}
export function composeDisplayRecordObjectMatrix(THREE, record) {
const baseMatrix = buildPartTransformMatrix(THREE, record?.baseTransform);
const effectMatrix = composeDisplayRecordEffectMatrix(THREE, record);
return effectMatrix ? effectMatrix.multiply(baseMatrix) : baseMatrix;
}
export function applyDisplayRecordTransform(THREE, record) {
if (!record) {
return;
}
const combinedMatrix = composeDisplayRecordObjectMatrix(THREE, record);
applyObjectMatrix(THREE, record.mesh, combinedMatrix);
applyObjectMatrix(THREE, record.edges, combinedMatrix);
applyObjectMatrix(THREE, record.silhouette, combinedMatrix);
}
@@ -0,0 +1,381 @@
import {
DEFAULT_STEP_CLIP_SETTINGS,
normalizeStepClipSettings,
stepClipSettingsEqual
} from "../lib/viewer/clipPlane.js";
import {
CAMERA_PROJECTION,
normalizeCameraProjection
} from "../lib/perspective.js";
export { CAMERA_PROJECTION, normalizeCameraProjection };
export const CAD_DISPLAY_MODE = Object.freeze({
HIDDEN_EDGES: "hidden_edges",
HIDDEN_LINES_REMOVED: "hidden_lines_removed",
RENDERED: "rendered",
SOLID: "solid",
TRANSPARENT: "transparent",
UNSHADED: "unshaded",
WIREFRAME: "wireframe"
});
export const CAD_DISPLAY_MODE_VALUES = Object.freeze(Object.values(CAD_DISPLAY_MODE));
export const EXPLODED_VIEW_AXES = Object.freeze(["x", "y", "z", "radial"]);
export const EXPLODED_VIEW_DIRECTIONS = Object.freeze(["positive", "negative"]);
export const MAX_EXPLODED_VIEW_DEPTH = 8;
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
export const CAD_EDGE_COLOR = "#132232";
export const CAD_EDGE_HIGHLIGHT_COLOR = "#8dc5ff";
export const CAD_EDGE_CLASS_IDS = Object.freeze(["feature", "tangent", "seam", "degenerate"]);
export const DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS = Object.freeze({
feature: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 1.15
}),
tangent: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.5,
thickness: 1.15
}),
seam: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.85,
thickness: 1.15
}),
degenerate: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 0
})
});
export const DEFAULT_DISPLAY_EDGE_SETTINGS = Object.freeze({
enabled: true,
contrastMode: "manual",
color: CAD_EDGE_COLOR,
thickness: 1,
classes: DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
highlightColor: CAD_EDGE_HIGHLIGHT_COLOR,
highlightOpacity: 1,
highlightThickness: 3,
silhouette: false,
silhouetteScale: 0
});
export const DISABLED_DISPLAY_EDGE_SETTINGS = Object.freeze({
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false
});
export const DEFAULT_EXPLODED_VIEW_SETTINGS = Object.freeze({
enabled: false,
axis: "z",
direction: "positive",
spacing: 1.45,
depth: 1,
keepBaseGrounded: true,
mergeCoplanar: false,
autoFrame: true
});
export const DEFAULT_DISPLAY_SETTINGS = Object.freeze({
projection: CAMERA_PROJECTION.ORTHOGRAPHIC,
mode: CAD_DISPLAY_MODE.SOLID,
clip: DEFAULT_STEP_CLIP_SETTINGS,
exploded: DEFAULT_EXPLODED_VIEW_SETTINGS,
edges: DEFAULT_DISPLAY_EDGE_SETTINGS
});
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeNumber(value, fallback, min = -Infinity, max = Infinity) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return fallback;
}
return clamp(numericValue, min, max);
}
function normalizeColor(value, fallback) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return fallback;
}
return normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`.toLowerCase()
: normalized.toLowerCase();
}
function normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeDisplayEdgeContrastMode(value, fallback = "manual") {
const normalized = String(value || "").trim().toLowerCase();
return ["auto", "manual"].includes(normalized)
? normalized
: fallback;
}
export function normalizeDisplayEdgeClassSettings(
value = {},
fallback = DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
colorFallback = CAD_EDGE_COLOR
) {
const source = isObject(value) ? value : {};
const fallbackColor = normalizeColor(colorFallback, CAD_EDGE_COLOR);
return Object.fromEntries(CAD_EDGE_CLASS_IDS.map((classId) => {
const classSource = isObject(source[classId]) ? source[classId] : {};
const classFallback = fallback?.[classId] || DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS[classId];
const legacyDisabled = classSource.enabled === false;
return [classId, {
color: normalizeColor(classSource.color, fallbackColor),
opacity: normalizeNumber(classSource.opacity, classFallback.opacity, 0, 1),
thickness: legacyDisabled
? 0
: normalizeNumber(classSource.thickness, classFallback.thickness, 0, 6)
}];
}));
}
export function normalizeDisplayEdgeSettings(value = null, fallback = DEFAULT_DISPLAY_EDGE_SETTINGS) {
const source = isObject(value) ? value : {};
const color = normalizeColor(source.color, fallback.color);
const normalized = {
enabled: normalizeBoolean(source.enabled, fallback.enabled),
contrastMode: normalizeDisplayEdgeContrastMode(source.contrastMode, fallback.contrastMode),
color,
thickness: normalizeNumber(source.thickness, fallback.thickness, 0.5, 6),
classes: normalizeDisplayEdgeClassSettings(source.classes, fallback.classes, color),
highlightColor: normalizeColor(source.highlightColor, fallback.highlightColor || CAD_EDGE_HIGHLIGHT_COLOR),
highlightOpacity: normalizeNumber(source.highlightOpacity, fallback.highlightOpacity || 1, 0, 1),
highlightThickness: normalizeNumber(source.highlightThickness, fallback.highlightThickness || 3, 0.5, 6),
silhouette: normalizeBoolean(source.silhouette, fallback.silhouette || false),
silhouetteScale: normalizeNumber(source.silhouetteScale, fallback.silhouetteScale || 0, 0, 0.04)
};
if (typeof source.depthTest === "boolean") {
normalized.depthTest = source.depthTest;
}
return normalized;
}
function normalizeModeText(value) {
return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_");
}
export function normalizeDisplayMode(value) {
const normalized = normalizeModeText(value);
if (!normalized) {
return CAD_DISPLAY_MODE.SOLID;
}
if (normalized === "wire" || normalized === "wire_frame") {
return CAD_DISPLAY_MODE.WIREFRAME;
}
if (
normalized === "edges" ||
normalized === "edge" ||
normalized === "shaded_edges" ||
normalized === "shaded_with_edges" ||
normalized === "with_edges"
) {
return CAD_DISPLAY_MODE.SOLID;
}
if (
normalized === "shaded" ||
normalized === "shaded_without_edges" ||
normalized === "without_edges"
) {
return CAD_DISPLAY_MODE.RENDERED;
}
if (
normalized === "translucent" ||
normalized === "xray" ||
normalized === "x_ray" ||
normalized === "see_through"
) {
return CAD_DISPLAY_MODE.TRANSPARENT;
}
if (
normalized === "hidden_edge" ||
normalized === "hidden_edges_visible" ||
normalized === "hidden_edge_display" ||
normalized === "shaded_hidden_edges"
) {
return CAD_DISPLAY_MODE.HIDDEN_EDGES;
}
if (
normalized === "visible_edges" ||
normalized === "visible_edges_only" ||
normalized === "hidden_lines" ||
normalized === "hidden_line_removed" ||
normalized === "hidden_lines_removed" ||
normalized === "hidden_edges_removed"
) {
return CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED;
}
if (normalized === "flat") {
return CAD_DISPLAY_MODE.UNSHADED;
}
if (normalized === "appearance" || normalized === "material" || normalized === "materials") {
return CAD_DISPLAY_MODE.RENDERED;
}
return CAD_DISPLAY_MODE_VALUES.includes(normalized)
? normalized
: CAD_DISPLAY_MODE.SOLID;
}
export function normalizeExplodedViewAxis(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.axis) {
const normalized = String(value || "").trim().toLowerCase();
const axis = normalized.startsWith("-") ? normalized.slice(1) : normalized;
return EXPLODED_VIEW_AXES.includes(axis) ? axis : fallback;
}
export function normalizeExplodedViewDirection(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.direction) {
const normalized = String(value || "").trim().toLowerCase();
if (["negative", "reverse", "down", "backward", "-", "-1"].includes(normalized)) {
return "negative";
}
if (["positive", "forward", "up", "+", "+1", "1"].includes(normalized)) {
return "positive";
}
return fallback;
}
export function normalizeExplodedViewDepth(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.depth) {
const normalized = String(value ?? "").trim().toLowerCase();
if (["all", "full", "parts", "leaves", "leaf"].includes(normalized)) {
return MAX_EXPLODED_VIEW_DEPTH;
}
const numericValue = Math.round(Number(value));
return Number.isFinite(numericValue)
? clamp(numericValue, 1, MAX_EXPLODED_VIEW_DEPTH)
: fallback;
}
export function normalizeExplodedViewSettings(value = null, overrides = {}) {
const source = isObject(value) ? value : {};
const merged = isObject(overrides) ? { ...source, ...overrides } : source;
const axisText = String(merged.axis || "").trim().toLowerCase();
return {
enabled: normalizeBoolean(merged.enabled, DEFAULT_EXPLODED_VIEW_SETTINGS.enabled),
axis: normalizeExplodedViewAxis(merged.axis),
direction: normalizeExplodedViewDirection(merged.direction || (axisText.startsWith("-") ? "negative" : "positive")),
spacing: normalizeNumber(merged.spacing ?? merged.distance ?? merged.distanceScale, DEFAULT_EXPLODED_VIEW_SETTINGS.spacing, 0.25, 4),
depth: normalizeExplodedViewDepth(merged.depth ?? merged.levels ?? merged.scopeDepth),
keepBaseGrounded: normalizeBoolean(merged.keepBaseGrounded ?? merged.groundBase, DEFAULT_EXPLODED_VIEW_SETTINGS.keepBaseGrounded),
mergeCoplanar: normalizeBoolean(merged.mergeCoplanar ?? merged.mergeLayers ?? merged.coalesceLayers, DEFAULT_EXPLODED_VIEW_SETTINGS.mergeCoplanar),
autoFrame: normalizeBoolean(merged.autoFrame, DEFAULT_EXPLODED_VIEW_SETTINGS.autoFrame)
};
}
export function normalizeDisplaySettings(value = null) {
const source = isObject(value) ? value : {};
const explodedOverrides = {};
if (isObject(source.exploded) && source.exploded.enabled !== undefined) {
explodedOverrides.enabled = source.exploded.enabled;
}
return {
projection: normalizeCameraProjection(source.projection, DEFAULT_DISPLAY_SETTINGS.projection),
mode: normalizeDisplayMode(source.mode),
clip: normalizeStepClipSettings(source.clip),
exploded: normalizeExplodedViewSettings(source.exploded, explodedOverrides),
edges: normalizeDisplayEdgeSettings(source.edges)
};
}
export function cloneDisplaySettings(value = DEFAULT_DISPLAY_SETTINGS) {
return normalizeDisplaySettings(value);
}
export function displaySettingsEqual(left, right) {
const a = normalizeDisplaySettings(left);
const b = normalizeDisplaySettings(right);
return a.projection === b.projection &&
a.mode === b.mode &&
stepClipSettingsEqual(a.clip, b.clip) &&
JSON.stringify(a.exploded) === JSON.stringify(b.exploded) &&
JSON.stringify(a.edges) === JSON.stringify(b.edges);
}
export function resolveDisplayMode(displaySettings) {
return normalizeDisplaySettings(displaySettings).mode;
}
export function resolveDisplayEdgeSettings(displaySettings) {
return normalizeDisplaySettings(displaySettings).edges;
}
export function displayModeIsWireframe(value) {
return normalizeDisplayMode(value) === CAD_DISPLAY_MODE.WIREFRAME;
}
export function displayModeForcesEdges(value) {
return [
CAD_DISPLAY_MODE.SOLID,
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED
].includes(normalizeDisplayMode(value));
}
export function displayModeAllowsEdges(value) {
return ![
CAD_DISPLAY_MODE.RENDERED,
CAD_DISPLAY_MODE.UNSHADED
].includes(normalizeDisplayMode(value));
}
export function displayModeShowsEdges(value, edgeSettings = null) {
const mode = normalizeDisplayMode(value);
return mode === CAD_DISPLAY_MODE.WIREFRAME ||
displayModeForcesEdges(mode);
}
export function displayModeShowsThroughEdges(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesTransparentSurfaces(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesUnlitSurfaces(value) {
return [
CAD_DISPLAY_MODE.UNSHADED,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeSurfaceOpacity(value, fallback = 1) {
const mode = normalizeDisplayMode(value);
if (mode === CAD_DISPLAY_MODE.WIREFRAME) {
return 0.035;
}
if (mode === CAD_DISPLAY_MODE.TRANSPARENT) {
return 0.22;
}
if (mode === CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED) {
return 0.045;
}
const numericFallback = Number(fallback);
return Number.isFinite(numericFallback) ? numericFallback : 1;
}
@@ -0,0 +1,29 @@
export const DEFAULT_ORBIT_FPS = 6;
export const DEFAULT_ORBIT_DURATION_SECONDS = 12;
export function orbitFrameOutputs(job) {
const orbit = job.orbit && typeof job.orbit === "object" ? job.orbit : {};
const output = Array.isArray(job.outputs) && job.outputs.length ? job.outputs[0] : {};
const width = Math.max(1, Math.floor(Number(output.width || job.width || orbit.width || 720)));
const height = Math.max(1, Math.floor(Number(output.height || job.height || orbit.height || 480)));
const fps = Math.max(1, Math.min(Number(orbit.fps || DEFAULT_ORBIT_FPS), 60));
const durationSeconds = Math.max(0.1, Math.min(Number(orbit.durationSeconds || DEFAULT_ORBIT_DURATION_SECONDS), 60));
const frameCount = Math.max(2, Math.min(Math.round(fps * durationSeconds), 720));
const startAzimuth = Number.isFinite(Number(orbit.startAzimuth)) ? Number(orbit.startAzimuth) : -45;
const elevation = Number.isFinite(Number(orbit.elevation)) ? Number(orbit.elevation) : 30;
const turns = Number.isFinite(Number(orbit.turns)) ? Number(orbit.turns) : 1;
return {
path: String(output.path || job.output || ""),
width,
height,
fps,
durationSeconds,
frameCount,
outputs: Array.from({ length: frameCount }, (_, index) => ({
path: "",
width,
height,
camera: `${startAzimuth + ((360 * turns * index) / frameCount)}:${elevation}`
}))
};
}
@@ -0,0 +1,232 @@
import gifencDefault, {
GIFEncoder as exportedGifEncoder,
applyPalette as exportedApplyPalette,
quantize as exportedQuantize
} from "gifenc";
import * as THREE from "three";
import {
buildModel
} from "./cadScene.js";
import {
captureModel,
modelOptionsForRenderJob,
renderJobContext,
renderModel
} from "./renderMeshScene.js";
import {
hasStepParameterRenderValues
} from "./stepParameters.js";
import {
loadSource,
stepParameterFrameRuntime
} from "./source.js";
import {
orbitFrameOutputs
} from "./headlessOrbitFrames.js";
const GIFEncoder = exportedGifEncoder || gifencDefault?.GIFEncoder || gifencDefault;
const quantize = exportedQuantize || gifencDefault?.quantize;
const applyPalette = exportedApplyPalette || gifencDefault?.applyPalette;
async function dataUrlToImageData(dataUrl, width, height) {
const image = new Image();
image.decoding = "async";
const loaded = new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error("Failed to load rendered orbit frame"));
});
image.src = dataUrl;
await loaded;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
return context.getImageData(0, 0, width, height);
}
function shouldEncodeTransparentGif(job = {}) {
const backgroundType = String(
job.appearance?.background?.type || ""
).toLowerCase();
return Boolean(job.render?.transparent) || backgroundType === "transparent";
}
function encodeGifFrameImageData(imageData, { transparent = false } = {}) {
if (!transparent) {
const palette = quantize(imageData.data, 256);
return {
indexed: applyPalette(imageData.data, palette),
palette,
transparent: false,
transparentIndex: 0
};
}
const palette = quantize(imageData.data, 256, {
format: "rgba4444",
oneBitAlpha: true
});
const transparentIndex = palette.findIndex((color) => Number(color?.[3]) <= 127);
return {
indexed: applyPalette(imageData.data, palette, "rgba4444"),
palette,
transparent: transparentIndex >= 0,
transparentIndex: Math.max(transparentIndex, 0)
};
}
async function capturePreparedSource(source, job) {
const context = renderJobContext(source.meshData, job);
const model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));
if (context.mode === "list" || context.mode === "section") {
try {
return await captureModel({ model, context }, { job });
} finally {
model.dispose();
}
}
const viewport = renderModel(THREE, model, { job, context });
try {
return await captureModel(viewport, { job });
} finally {
viewport.dispose();
}
}
async function renderOrbit(source, job) {
const orbit = orbitFrameOutputs(job);
const frameResult = await capturePreparedSource(source, {
...job,
mode: "view",
outputs: orbit.outputs,
render: {
...(job.render || {}),
lockFraming: true
}
});
const encoder = GIFEncoder();
const transparent = shouldEncodeTransparentGif(job);
for (let index = 0; index < frameResult.outputs.length; index += 1) {
const imageData = await dataUrlToImageData(frameResult.outputs[index].dataUrl, orbit.width, orbit.height);
const frame = encodeGifFrameImageData(imageData, { transparent });
encoder.writeFrame(frame.indexed, orbit.width, orbit.height, {
palette: frame.palette,
transparent: frame.transparent,
transparentIndex: frame.transparentIndex,
delay: 1000 / orbit.fps,
repeat: 0,
dispose: frame.transparent ? 2 : -1
});
}
encoder.finish();
const bytes = encoder.bytesView();
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return {
ok: true,
mode: "orbit",
outputs: [{
path: orbit.path,
width: orbit.width,
height: orbit.height,
frameCount: orbit.frameCount,
mimeType: "image/gif",
dataUrl: `data:image/gif;base64,${btoa(binary)}`
}],
timings: frameResult.timings,
warnings: frameResult.warnings || []
};
}
async function renderParamAnimation(source, job, stepParameterSource) {
const params = stepParameterSource.renderParameters;
const output = Array.isArray(job.outputs) && job.outputs.length ? job.outputs[0] : {};
const width = Math.max(1, Math.floor(Number(output.width || job.width || 720)));
const height = Math.max(1, Math.floor(Number(output.height || job.height || 480)));
const frameOutputs = Array.from({ length: params.frameCount }, (_, index) => ({
...output,
path: "",
width,
height,
stepParameters: stepParameterFrameRuntime(stepParameterSource, index)
}));
const frameResult = await capturePreparedSource(source, {
...job,
outputs: frameOutputs,
render: {
...(job.render || {}),
lockFraming: true
}
});
const encoder = GIFEncoder();
const transparent = shouldEncodeTransparentGif(job);
for (let index = 0; index < frameResult.outputs.length; index += 1) {
const imageData = await dataUrlToImageData(frameResult.outputs[index].dataUrl, width, height);
const frame = encodeGifFrameImageData(imageData, { transparent });
encoder.writeFrame(frame.indexed, width, height, {
palette: frame.palette,
transparent: frame.transparent,
transparentIndex: frame.transparentIndex,
delay: 1000 / params.fps,
repeat: params.loop === false ? -1 : 0,
dispose: frame.transparent ? 2 : -1
});
}
encoder.finish();
const bytes = encoder.bytesView();
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return {
ok: true,
mode: String(job.mode || "view").toLowerCase(),
outputs: [{
path: String(output.path || job.output || ""),
width,
height,
frameCount: params.frameCount,
fps: params.fps,
durationSeconds: params.durationSeconds,
loop: params.loop !== false,
mimeType: "image/gif",
dataUrl: `data:image/gif;base64,${btoa(binary)}`
}],
timings: frameResult.timings,
warnings: frameResult.warnings || []
};
}
export async function runHeadlessRenderJob(job) {
const source = await loadSource(job);
const stepParameterSource = source.stepParameterSource;
const explicitParams = hasStepParameterRenderValues(job.stepParameters);
const renderJob = {
...job,
selectorRuntime: source.selectorRuntime,
displayEdgeRuntime: source.displayEdgeRuntime
};
if (stepParameterSource && explicitParams && String(job.mode || "view").toLowerCase() !== "view") {
throw new Error("stepParameters support only view mode; set display.mode for display-style changes");
}
const renderJobWithStepParameters = stepParameterSource
? {
...renderJob,
stepParameters: stepParameterFrameRuntime(stepParameterSource, 0)
}
: renderJob;
if (stepParameterSource?.renderParameters?.animated) {
return renderParamAnimation(source, renderJob, stepParameterSource);
}
if (String(job.mode || "view").toLowerCase() === "orbit") {
return renderOrbit(source, renderJobWithStepParameters);
}
return capturePreparedSource(source, renderJobWithStepParameters);
}
if (typeof window !== "undefined") {
window.__snapshotRender = runHeadlessRenderJob;
}
@@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>CAD Viewer headless render</title>
<style>
html,
body {
margin: 0;
min-width: 100%;
min-height: 100%;
overflow: hidden;
background: transparent;
}
</style>
<script type="importmap">
{
"imports": {
"three": "/node_modules/three/build/three.module.js",
"three/examples/": "/node_modules/three/examples/",
"gifenc": "/node_modules/gifenc/dist/gifenc.esm.js"
}
}
</script>
</head>
<body>
<script type="module" src="/common/headlessRenderEntry.js"></script>
</body>
</html>
@@ -0,0 +1,661 @@
import {
buildTopologyDisplayEdgePolylines,
buildTopologyDisplayEdgePositions
} from "./topologyDisplayEdges.js";
import {
displayModeIsWireframe
} from "./displaySettings.js";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
const DEFAULT_LINE_DEPTH_BIAS = 0;
// Keep topology edges slightly above coplanar faces without letting nearby solids lose the depth test.
export const TOPOLOGY_LINE_DEPTH_BIAS = 0.0045;
const TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL = 0.00025;
const MAX_TOPOLOGY_LINE_DEPTH_BIAS = 0.006;
const COPLANAR_TOPOLOGY_EDGE_CLASSES = new Set(["tangent", "seam"]);
const COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS = 0.006;
const COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL = 0.00025;
const MAX_COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS = 0.008;
const DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES = Object.freeze(["feature"]);
const TOPOLOGY_EDGE_CLASS_ORDER = Object.freeze(["feature", "tangent", "seam", "degenerate"]);
const MAX_TOPOLOGY_LINE_STRIP_POLYLINES = 1200;
const MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES = 180000;
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
const DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS = Object.freeze({
feature: Object.freeze({ color: "#132232", opacity: 1, thickness: 1.15 }),
tangent: Object.freeze({ color: "#132232", opacity: 0.5, thickness: 1.15 }),
seam: Object.freeze({ color: "#132232", opacity: 0.85, thickness: 1.15 }),
degenerate: Object.freeze({ color: "#132232", opacity: 1, thickness: 0 })
});
export function topologyLineDepthBiasForWidth(lineWidth = 1, { visibilityClass = "" } = {}) {
const width = Number.isFinite(Number(lineWidth))
? Math.max(1, Number(lineWidth))
: 1;
const normalizedClass = String(visibilityClass || "").trim().toLowerCase();
const baseBias = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass)
? COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS
: TOPOLOGY_LINE_DEPTH_BIAS;
const biasPerExtraPixel = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass)
? COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL
: TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL;
const maxBias = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass)
? MAX_COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS
: MAX_TOPOLOGY_LINE_DEPTH_BIAS;
return clamp(
baseBias + Math.max(0, width - 1) * biasPerExtraPixel,
baseBias,
maxBias
);
}
export function lineSegmentPositionsFromGeometry(geometry) {
const positionAttribute = geometry?.getAttribute?.("position");
const rawPositions = positionAttribute?.array;
if (!positionAttribute?.count || !rawPositions?.length) {
return null;
}
return rawPositions;
}
export function syncLineMaterialOpacity(material, opacity) {
if (!material) {
return;
}
const nextOpacity = clamp(Number(opacity) || 0, 0, 1);
const nextTransparent = nextOpacity < 0.999;
material.opacity = nextOpacity;
material.depthWrite = false;
if (material.transparent !== nextTransparent) {
material.transparent = nextTransparent;
material.needsUpdate = true;
}
}
export function syncScreenSpaceLineMaterialResolution(materials, width, height) {
const nextWidth = Math.max(1, Math.floor(Number(width) || 1));
const nextHeight = Math.max(1, Math.floor(Number(height) || 1));
for (const material of materials || []) {
material?.resolution?.set?.(nextWidth, nextHeight);
}
}
function registerLineMaterial(context = {}, material, materials = null) {
materials?.add?.(material);
context.registerScreenSpaceLineMaterial?.(material);
}
function unregisterLineMaterial(context = {}, material, materials = null) {
materials?.delete?.(material);
context.unregisterScreenSpaceLineMaterial?.(material);
}
function normalizePartIds(value) {
return (Array.isArray(value) ? value : [value])
.map((id) => String(id || "").trim())
.filter(Boolean);
}
function occurrenceMatchesPartIds(occurrenceId, partIds = []) {
const normalizedOccurrenceId = String(occurrenceId || "").trim();
if (!normalizedOccurrenceId || !partIds.length) {
return false;
}
return partIds.some((partId) => (
normalizedOccurrenceId === partId ||
normalizedOccurrenceId.startsWith(`${partId}.`)
));
}
function topologyEdgeRowMatchesPartIds(row, partIds = []) {
return occurrenceMatchesPartIds(row?.occurrenceId || row?.partId || row?.id, partIds);
}
function topologyEdgeRowMatchesLineStripFilters(row, {
includePartIds = [],
excludePartIds = [],
visibilityClasses = []
} = {}) {
if (includePartIds.length && !topologyEdgeRowMatchesPartIds(row, includePartIds)) {
return false;
}
if (excludePartIds.length && topologyEdgeRowMatchesPartIds(row, excludePartIds)) {
return false;
}
if (visibilityClasses.length) {
const visibilityClass = String(row?.visibilityClass || "feature").trim().toLowerCase() || "feature";
if (!visibilityClasses.includes(visibilityClass)) {
return false;
}
}
return true;
}
function shouldBuildTopologyLineStrips(selectorRuntime, filters = {}) {
const edgeRows = Array.isArray(selectorRuntime?.edges) ? selectorRuntime.edges : [];
if (!edgeRows.length) {
return false;
}
let polylineCount = 0;
let positionValueCount = 0;
for (const row of edgeRows) {
if (!topologyEdgeRowMatchesLineStripFilters(row, filters)) {
continue;
}
const segmentCount = Number.isFinite(Number(row?.segmentCount))
? Math.max(1, Number(row.segmentCount))
: 1;
polylineCount += 1;
positionValueCount += (segmentCount + 1) * 3;
if (
polylineCount > MAX_TOPOLOGY_LINE_STRIP_POLYLINES ||
positionValueCount > MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES
) {
return false;
}
}
return polylineCount > 0;
}
function normalizeVisibilityClasses(value, fallback = DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES) {
const values = (Array.isArray(value) ? value : [value])
.map((item) => String(item || "").trim().toLowerCase())
.filter(Boolean);
return values.length ? values : fallback;
}
function normalizeColor(value, fallback) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return fallback;
}
return normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`.toLowerCase()
: normalized.toLowerCase();
}
function normalizeTopologyEdgeClassSettings(edgeSettings = {}, baseTheme = {}, fallbackClasses = DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES) {
const classSettings = edgeSettings?.classes && typeof edgeSettings.classes === "object"
? edgeSettings.classes
: null;
const explicitVisibilityClasses = classSettings
? []
: normalizeVisibilityClasses(edgeSettings?.visibilityClasses, fallbackClasses);
const baseOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (baseTheme?.edgeOpacity ?? 0.84);
const baseThickness = Number.isFinite(Number(edgeSettings?.thickness))
? clamp(Number(edgeSettings.thickness), 0.5, 6)
: (baseTheme?.edgeThickness ?? 1);
const baseColor = normalizeColor(edgeSettings?.color, baseTheme?.edge || "#18181b");
if (!classSettings) {
return explicitVisibilityClasses.map((classId) => ({
classId,
color: baseColor,
opacity: baseOpacity,
thickness: baseThickness
}));
}
return TOPOLOGY_EDGE_CLASS_ORDER
.map((classId) => {
const classValue = classSettings[classId] || DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId];
const classOpacity = Number.isFinite(Number(classValue.opacity))
? clamp(Number(classValue.opacity), 0, 1)
: DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId].opacity;
const classThickness = Number.isFinite(Number(classValue.thickness))
? clamp(Number(classValue.thickness), 0, 6)
: DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId].thickness;
const classColor = normalizeColor(classValue.color, baseColor);
if (classThickness <= 0 || classOpacity <= 0) {
return null;
}
return {
classId,
color: classColor,
opacity: classOpacity,
thickness: classThickness
};
})
.filter(Boolean);
}
function runtimeHasVisibilityClassRows(selectorRuntime) {
return Array.isArray(selectorRuntime?.edges) &&
selectorRuntime.edges.some((row) => String(row?.visibilityClass || "").trim());
}
export function createScreenSpaceLineSegments(context = {}, positions, {
color,
opacity = 1,
lineWidth = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}, materials = null) {
const LineSegments2 = context.LineSegments2;
const LineSegmentsGeometry = context.LineSegmentsGeometry;
const LineMaterial = context.LineMaterial;
if (
!LineSegments2 ||
!LineSegmentsGeometry ||
!LineMaterial ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
!positions.length
) {
return null;
}
const lineGeometry = new LineSegmentsGeometry();
lineGeometry.setPositions(positions);
const lineMaterial = createScreenSpaceLineMaterial(LineMaterial, {
color,
opacity,
lineWidth,
depthTest,
depthWrite,
depthBias
});
registerLineMaterial(context, lineMaterial, materials);
const line = new LineSegments2(lineGeometry, lineMaterial);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.beforeDispose = () => {
unregisterLineMaterial(context, lineMaterial, materials);
};
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
function createScreenSpaceLineMaterial(LineMaterial, {
color,
opacity = 1,
lineWidth = 1,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}) {
const lineMaterial = new LineMaterial({
color,
linewidth: lineWidth,
opacity,
depthTest,
depthWrite,
toneMapped: false,
worldUnits: false
});
syncLineMaterialOpacity(lineMaterial, opacity);
applyLineDepthBias(lineMaterial, depthBias);
return lineMaterial;
}
export function applyLineDepthBias(material, depthBias = DEFAULT_LINE_DEPTH_BIAS) {
const bias = clamp(Number(depthBias) || 0, 0, 0.01);
if (!material || bias <= 0) {
return material;
}
material.polygonOffset = true;
material.polygonOffsetFactor = 0;
material.polygonOffsetUnits = -clamp(Math.round(bias * 1000), 1, 10);
material.needsUpdate = true;
return material;
}
export function createScreenSpaceLineStrip(context = {}, positions, {
color,
opacity = 1,
lineWidth = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}, materials = null) {
const Line2 = context.Line2;
const LineGeometry = context.LineGeometry;
const LineMaterial = context.LineMaterial;
if (
!Line2 ||
!LineGeometry ||
!LineMaterial ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
positions.length < 6
) {
return null;
}
const lineGeometry = new LineGeometry();
lineGeometry.setPositions(positions);
const lineMaterial = createScreenSpaceLineMaterial(LineMaterial, {
color,
opacity,
lineWidth,
depthTest,
depthWrite,
depthBias
});
registerLineMaterial(context, lineMaterial, materials);
const line = new Line2(lineGeometry, lineMaterial);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.beforeDispose = () => {
unregisterLineMaterial(context, lineMaterial, materials);
};
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
export function createBasicLineStrip(context = {}, positions, {
color,
opacity = 1,
lineWidth = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}) {
const THREE = context.THREE;
if (
!THREE ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
positions.length < 6
) {
return null;
}
const geometry = new THREE.BufferGeometry();
const positionArray = positions instanceof Float32Array ? positions : new Float32Array(positions);
geometry.setAttribute("position", new THREE.BufferAttribute(positionArray, 3));
const material = new THREE.LineBasicMaterial({
color,
linewidth: Number.isFinite(Number(lineWidth)) ? Number(lineWidth) : 1,
transparent: Number(opacity) < 0.999,
opacity: clamp(Number(opacity) || 0, 0, 1),
depthTest,
depthWrite,
toneMapped: false
});
applyLineDepthBias(material, depthBias);
const line = new THREE.Line(geometry, material);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
export function createBasicLineSegments(context = {}, positions, {
color,
opacity = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}) {
const THREE = context.THREE;
if (
!THREE ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
positions.length < 6
) {
return null;
}
const geometry = new THREE.BufferGeometry();
const positionArray = positions instanceof Float32Array ? positions : new Float32Array(positions);
geometry.setAttribute("position", new THREE.BufferAttribute(positionArray, 3));
const material = new THREE.LineBasicMaterial({
color,
transparent: Number(opacity) < 0.999,
opacity: clamp(Number(opacity) || 0, 0, 1),
depthTest,
depthWrite,
toneMapped: false
});
applyLineDepthBias(material, depthBias);
const line = new THREE.LineSegments(geometry, material);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
export function createScreenSpaceLineSegmentsFromGeometry(context, geometry, options, materials = null) {
const positions = lineSegmentPositionsFromGeometry(geometry);
return positions ? createScreenSpaceLineSegments(context, positions, options, materials) : null;
}
export function createDisplayEdgeObject(context = {}, {
geometry,
edgeSettings,
baseTheme,
partId,
displayMode,
thickness,
wireframeEdgeColor = ""
}, materials = null) {
const THREE = context.THREE;
const wireframeMode = displayModeIsWireframe(displayMode);
const depthTest = edgeSettings?.depthTest === false ? false : true;
const edgeOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (baseTheme?.edgeOpacity ?? 0.84);
const color = wireframeMode
? (wireframeEdgeColor || edgeSettings?.color || baseTheme?.edge || "#18181b")
: (edgeSettings?.color || baseTheme?.edge || "#18181b");
if (wireframeMode && THREE) {
const opacity = Math.max(edgeOpacity, 0.9);
const material = new THREE.LineBasicMaterial({
color,
transparent: opacity < 0.999,
opacity,
depthTest: false,
depthWrite: false,
toneMapped: false
});
const line = new THREE.LineSegments(geometry, material);
line.renderOrder = 4;
line.frustumCulled = false;
line.userData.partId = partId;
return { edgeMesh: line, edgeMaterial: material };
}
const line = createScreenSpaceLineSegmentsFromGeometry(context, geometry, {
color,
opacity: edgeOpacity,
lineWidth: Number.isFinite(Number(thickness)) ? Number(thickness) : 1,
renderOrder: 3,
depthTest,
depthWrite: false,
depthBias: topologyLineDepthBiasForWidth(thickness)
}, materials);
if (!line) {
return { edgeMesh: null, edgeMaterial: null };
}
line.userData.partId = partId;
return { edgeMesh: line, edgeMaterial: line.material };
}
export function createTopologyDisplayEdgeObject(context = {}, selectorRuntime, edgeSettings, baseTheme, materials = null) {
const includePartIds = normalizePartIds(edgeSettings?.includePartIds);
const excludePartIds = normalizePartIds(edgeSettings?.excludePartIds);
const focusedPartIds = normalizePartIds(edgeSettings?.focusedPartIds);
const highlightPartIds = normalizePartIds(edgeSettings?.highlightPartIds);
const edgeClassSettings = normalizeTopologyEdgeClassSettings(edgeSettings, baseTheme)
.filter((classSetting) => (
classSetting.classId === "feature" ||
runtimeHasVisibilityClassRows(selectorRuntime)
));
const visibilityClasses = edgeClassSettings.map((setting) => setting.classId);
const edgeOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (baseTheme?.edgeOpacity ?? 0.84);
const highlightOpacity = Number.isFinite(Number(edgeSettings?.highlightOpacity))
? clamp(Number(edgeSettings.highlightOpacity), 0, 1)
: edgeOpacity;
const dimmedOpacity = Number.isFinite(Number(edgeSettings?.dimmedOpacity))
? clamp(Number(edgeSettings.dimmedOpacity), 0, 1)
: edgeOpacity;
const thickness = Number.isFinite(Number(edgeSettings?.thickness))
? clamp(Number(edgeSettings.thickness), 0.5, 6)
: (baseTheme?.edgeThickness ?? 1);
const baseOptions = {
color: edgeSettings?.color || baseTheme?.edge || "#18181b",
lineWidth: thickness,
renderOrder: 3,
depthTest: edgeSettings?.depthTest === false ? false : true,
depthWrite: false,
depthBias: topologyLineDepthBiasForWidth(thickness)
};
const createLine = (positions, options) => (
createScreenSpaceLineSegments(context, positions, options, materials) ||
createBasicLineSegments(context, positions, options)
);
const createLineStrip = (positions, options = {}) => {
const lineWidth = Number.isFinite(Number(options.lineWidth)) ? Number(options.lineWidth) : 1;
if (lineWidth <= 1.05) {
return (
createBasicLineStrip(context, positions, options) ||
createScreenSpaceLineStrip(context, positions, options, materials)
);
}
return (
createScreenSpaceLineStrip(context, positions, options, materials) ||
createBasicLineStrip(context, positions, options)
);
};
const createClassGroup = (children) => {
const lines = children.filter(Boolean);
if (!lines.length) {
return null;
}
if (lines.length === 1) {
return lines[0];
}
const group = context.THREE ? new context.THREE.Group() : null;
if (!group) {
return lines[0];
}
for (const line of lines) {
group.add(line);
}
return group;
};
const createLineStripGroup = (polylines, options) => {
if (!context.THREE || !Array.isArray(polylines) || !polylines.length) {
return null;
}
const positionValueCount = polylines.reduce((sum, positions) => sum + (positions?.length || 0), 0);
if (
polylines.length > MAX_TOPOLOGY_LINE_STRIP_POLYLINES ||
positionValueCount > MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES
) {
return null;
}
return createClassGroup(polylines.map((positions) => createLineStrip(positions, options)));
};
const createClassLine = (classSetting, options = {}) => {
const lineWidth = Number.isFinite(Number(options.lineWidth))
? Number(options.lineWidth)
: classSetting.thickness;
const filters = {
includePartIds: options.includePartIds || includePartIds,
excludePartIds: options.excludePartIds || excludePartIds,
visibilityClasses: [classSetting.classId]
};
const lineOptions = {
...baseOptions,
...options,
color: options.color || classSetting.color || baseOptions.color,
lineWidth,
opacity: Number.isFinite(Number(options.opacity)) ? options.opacity : classSetting.opacity,
depthBias: Number.isFinite(Number(options.depthBias))
? options.depthBias
: topologyLineDepthBiasForWidth(lineWidth, { visibilityClass: classSetting.classId })
};
const polylines = shouldBuildTopologyLineStrips(selectorRuntime, filters)
? buildTopologyDisplayEdgePolylines(selectorRuntime, filters)
: [];
const stripGroup = createLineStripGroup(polylines, lineOptions);
if (stripGroup) {
return stripGroup;
}
const positions = buildTopologyDisplayEdgePositions(selectorRuntime, {
...filters
});
if (!positions?.length) {
return null;
}
return createLine(positions, lineOptions);
};
if (highlightPartIds.length) {
const positions = buildTopologyDisplayEdgePositions(selectorRuntime, {
includePartIds: highlightPartIds,
visibilityClasses
});
if (!positions?.length) {
return null;
}
const line = createLine(positions, {
...baseOptions,
color: edgeSettings?.highlightColor || baseOptions.color,
opacity: highlightOpacity,
depthBias: Number.isFinite(Number(edgeSettings?.highlightDepthBias))
? clamp(Number(edgeSettings.highlightDepthBias), 0, 0.01)
: 0,
renderOrder: Number.isFinite(Number(edgeSettings?.highlightRenderOrder))
? Number(edgeSettings.highlightRenderOrder)
: 26
});
if (line) {
line.name = "TopologyDisplayEdgeHighlights";
line.userData.partId = "__topology_highlight__";
}
return line;
}
if (focusedPartIds.length && context.THREE) {
const group = new context.THREE.Group();
for (const classSetting of edgeClassSettings) {
const dimmedLine = createClassLine(classSetting, {
excludePartIds: focusedPartIds,
opacity: Math.min(dimmedOpacity, classSetting.opacity),
renderOrder: 3
});
const focusedLine = createClassLine(classSetting, {
includePartIds: focusedPartIds,
opacity: classSetting.opacity,
renderOrder: 4
});
if (dimmedLine) {
group.add(dimmedLine);
}
if (focusedLine) {
group.add(focusedLine);
}
}
if (!group.children.length) {
return null;
}
group.name = "TopologyDisplayEdges";
group.userData.partId = "__topology__";
return group;
}
const classLines = edgeClassSettings.map((classSetting) => createClassLine(classSetting));
const line = createClassGroup(classLines);
if (!line) {
return null;
}
line.name = "TopologyDisplayEdges";
line.userData.partId = "__topology__";
return line;
}
@@ -0,0 +1,223 @@
import {
fitCameraToModel
} from "./cadScene.js";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function addLight(scene, light, enabled = true) {
light.visible = enabled;
scene.add(light);
return light;
}
function applyViewportLighting(THREE, scene, theme = {}) {
const lighting = theme.lighting || {};
addLight(
scene,
new THREE.HemisphereLight(
lighting.hemisphere?.skyColor || "#ffffff",
lighting.hemisphere?.groundColor || "#101014",
toFiniteNumber(lighting.hemisphere?.intensity, 0)
),
lighting.hemisphere?.enabled === true
);
addLight(
scene,
new THREE.AmbientLight(
lighting.ambient?.color || "#ffffff",
toFiniteNumber(lighting.ambient?.intensity, 0.2)
),
lighting.ambient?.enabled !== false
);
const directionalLights = Array.isArray(lighting.directionalLights)
? lighting.directionalLights
: [lighting.directional || {}];
for (const config of directionalLights) {
const light = new THREE.DirectionalLight(
config.color || "#ffffff",
toFiniteNumber(config.intensity, 1)
);
const position = config.position || {};
light.position.set(
toFiniteNumber(position.x, 160),
toFiniteNumber(position.y, -140),
toFiniteNumber(position.z, 240)
);
light.castShadow = config.castShadow === true;
addLight(scene, light, config.enabled !== false);
}
}
function resolveViewportSize(hostElement, canvas, fallbackWidth = 1400, fallbackHeight = 900) {
const rect = hostElement?.getBoundingClientRect?.();
const width = Math.max(1, Math.floor(rect?.width || hostElement?.clientWidth || canvas?.clientWidth || fallbackWidth));
const height = Math.max(1, Math.floor(rect?.height || hostElement?.clientHeight || canvas?.clientHeight || fallbackHeight));
return { width, height };
}
function applyBackground(THREE, renderer, scene, theme = {}, { alpha = false } = {}) {
const background = theme.background || {};
if (alpha || background.type === "transparent") {
scene.background = null;
renderer.setClearColor(new THREE.Color("#000000"), 0);
return;
}
const color = background.solidColor || background.color || "#ffffff";
scene.background = new THREE.Color(color);
renderer.setClearColor(new THREE.Color(color), 1);
}
export function renderModel(THREE, model, options = {}) {
if (!THREE) {
throw new Error("renderModel requires THREE");
}
if (!model?.root) {
throw new Error("renderModel requires a model returned by buildModel");
}
const canvas = options.canvas || undefined;
const hostElement = options.hostElement || options.container || canvas?.parentElement || null;
const theme = options.theme || options.themeSettings || {};
const alpha = options.alpha === true;
const renderer = options.renderer || new THREE.WebGLRenderer({
canvas,
alpha,
antialias: options.antialias !== false,
powerPreference: options.powerPreference || "high-performance",
preserveDrawingBuffer: options.preserveDrawingBuffer === true,
logarithmicDepthBuffer: options.logarithmicDepthBuffer !== false
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = options.shadows !== false;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = options.scene || new THREE.Scene();
applyBackground(THREE, renderer, scene, theme, { alpha });
applyViewportLighting(THREE, scene, theme);
scene.add(model.root);
const camera = options.camera || new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 10000);
const frame = {
direction: options.direction || options.view?.direction || [1, -1, 0.8],
up: options.up || options.view?.up || [0, 0, 1],
padding: toFiniteNumber(options.padding, 0.12),
scale: options.scale || options.sceneScale || "cad",
lockedHalfHeightScale: options.lockedHalfHeightScale || null
};
let disposed = false;
let rafId = 0;
let lastRenderTime = typeof performance !== "undefined" ? performance.now() : 0;
const resize = () => {
const { width, height } = resolveViewportSize(hostElement, renderer.domElement, options.width, options.height);
renderer.setPixelRatio(clamp(toFiniteNumber(options.pixelRatio, globalThis.devicePixelRatio || 1), 1, options.maxPixelRatio || 2));
renderer.setSize(width, height, false);
model.runtime?.syncScreenSpaceLineMaterials?.(width, height);
const fit = fitCameraToModel(THREE, camera, model.bounds, {
direction: frame.direction,
up: frame.up,
width,
height,
padding: frame.padding,
scale: frame.scale
});
if (frame.lockedHalfHeightScale) {
fitCameraToModel(THREE, camera, model.bounds, {
direction: frame.direction,
lockedHalfHeight: fit.halfHeight * toFiniteNumber(frame.lockedHalfHeightScale, 1),
up: frame.up,
width,
height,
padding: frame.padding,
scale: frame.scale
});
}
return { width, height, fit };
};
const render = () => {
renderer.render(scene, camera);
};
const step = () => {
if (disposed) {
return;
}
const now = typeof performance !== "undefined" ? performance.now() : lastRenderTime;
const deltaSeconds = Math.min(Math.max((now - lastRenderTime) / 1000, 0), 0.1);
lastRenderTime = now;
options.beforeRender?.({ deltaSeconds, viewport: api });
render();
rafId = globalThis.requestAnimationFrame?.(step) || 0;
};
const start = () => {
if (!rafId) {
lastRenderTime = typeof performance !== "undefined" ? performance.now() : 0;
rafId = globalThis.requestAnimationFrame?.(step) || 0;
}
};
const stop = () => {
if (rafId) {
globalThis.cancelAnimationFrame?.(rafId);
rafId = 0;
}
};
const resizeObserver = options.autoResize === false || typeof ResizeObserver !== "function" || !hostElement
? null
: new ResizeObserver(() => {
resize();
if (options.autoRender === false) {
render();
}
});
resizeObserver?.observe(hostElement);
const api = {
THREE,
model,
renderer,
scene,
camera,
ready: Promise.resolve(),
resize,
render,
start,
stop,
capturePng() {
render();
return renderer.domElement.toDataURL("image/png");
},
dispose() {
if (disposed) {
return;
}
disposed = true;
stop();
resizeObserver?.disconnect();
scene.remove(model.root);
if (options.disposeModel !== false) {
model.dispose?.();
}
if (!options.renderer) {
renderer.dispose();
}
}
};
resize();
if (options.autoStart === true) {
start();
} else if (options.autoRender !== false) {
render();
}
return api;
}
@@ -0,0 +1,573 @@
import * as THREE from "three";
import {
RENDER_CAMERA_PRESETS,
TOP_VIEW_UP,
WORLD_UP,
normalizeCameraZoom,
resolveCameraSnapshot,
resolveCameraView
} from "./camera.js";
import {
cloneThemeSettings,
DEFAULT_FLOOR_GRID_SETTINGS,
getEnvironmentPresetById,
MAX_FLOOR_GRID_DENSITY,
MIN_FLOOR_GRID_DENSITY,
THEME_FLOOR_MODES,
normalizeThemeSettings,
resolveThemeSettingsForColorMode
} from "./themeSettings.js";
import {
createCadWebGlRenderer
} from "./webglRenderer.js";
export const RENDER_SCENE_SCALE = Object.freeze({
CAD: "cad",
URDF: "urdf"
});
export { TOP_VIEW_UP, WORLD_UP };
export const RENDER_VIEW_PRESETS = RENDER_CAMERA_PRESETS;
export const SHARED_RENDER_OPTION_KEYS = Object.freeze([
"themeSettings",
"display",
"camera",
"framing",
"sceneScale",
"clip",
"selection",
"floor",
"background",
"lighting",
"outputSize",
"renderScale"
]);
/**
* SharedRenderOptions is the policy-free shape consumed by lower-level render
* helpers after snapshot or viewer code has already chosen defaults.
*
* @typedef {Object} SharedRenderOptions
* @property {Object|null} themeSettings Explicit, normalized appearance settings.
* @property {Object|null} display Explicit display settings.
* @property {Object|null} camera Explicit camera/view state.
* @property {Object|null} framing Explicit framing settings.
* @property {string|null} sceneScale Explicit CAD/URDF scene scale.
* @property {Object|null} clip Explicit clip settings.
* @property {Object|null} selection Explicit part/selection settings.
* @property {Object|null} floor Explicit floor settings.
* @property {Object|null} background Explicit background settings.
* @property {Object|null} lighting Explicit lighting settings.
* @property {Object|null} outputSize Explicit output width/height.
* @property {number|null} renderScale Explicit render scale.
*/
export function createSharedRenderOptions(explicitOptions = {}) {
const result = {};
for (const key of SHARED_RENDER_OPTION_KEYS) {
result[key] = Object.prototype.hasOwnProperty.call(explicitOptions, key)
? explicitOptions[key]
: null;
}
return result;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
export function normalizeRenderSceneScale(value) {
return value === RENDER_SCENE_SCALE.URDF ? RENDER_SCENE_SCALE.URDF : RENDER_SCENE_SCALE.CAD;
}
export function renderSceneScaleSettings(value, settingsByScale) {
return settingsByScale[normalizeRenderSceneScale(value)];
}
export function inferRenderSceneScale({
explicit = "",
kind = "",
parts = []
} = {}) {
const normalizedExplicit = String(explicit || "").trim().toLowerCase();
if (normalizedExplicit) {
return normalizeRenderSceneScale(normalizedExplicit);
}
const normalizedKind = String(kind || "").trim().toLowerCase();
if (normalizedKind === "urdf" || normalizedKind === "srdf" || normalizedKind === "sdf") {
return RENDER_SCENE_SCALE.URDF;
}
return (Array.isArray(parts) ? parts : []).some((part) => String(part?.linkName || "").trim())
? RENDER_SCENE_SCALE.URDF
: RENDER_SCENE_SCALE.CAD;
}
export function resolveAppearanceJobConfig(job = {}, { defaultThemeId = "workbench" } = {}) {
if (typeof job.appearance === "string") {
return {
themeId: job.appearance,
settings: null
};
}
if (job.appearance && typeof job.appearance === "object" && !Array.isArray(job.appearance)) {
return {
themeId: defaultThemeId,
settings: job.appearance
};
}
return {
themeId: defaultThemeId,
settings: null
};
}
export function resolveAppearanceSettings(job = {}, { defaultThemeId = "workbench" } = {}) {
const appearance = resolveAppearanceJobConfig(job, { defaultThemeId });
const themeSettings = cloneThemeSettings(appearance.themeId || defaultThemeId);
const normalized = normalizeThemeSettings(appearance.settings || themeSettings);
return typeof job.appearance === "string"
? resolveThemeSettingsForColorMode(normalized, { prefersDark: false })
: normalized;
}
export function resolveRenderView(camera = "iso", viewPresets = RENDER_VIEW_PRESETS, {
strict = false
} = {}) {
return resolveCameraView(camera, {
presets: viewPresets,
strict
});
}
export function boundsFromVertices(vertices) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let index = 0; index + 2 < (vertices?.length || 0); index += 3) {
const x = Number(vertices[index]);
const y = Number(vertices[index + 1]);
const z = Number(vertices[index + 2]);
if (![x, y, z].every(Number.isFinite)) {
continue;
}
min[0] = Math.min(min[0], x);
min[1] = Math.min(min[1], y);
min[2] = Math.min(min[2], z);
max[0] = Math.max(max[0], x);
max[1] = Math.max(max[1], y);
max[2] = Math.max(max[2], z);
}
if (!min.every(Number.isFinite) || !max.every(Number.isFinite)) {
return { min: [0, 0, 0], max: [1, 1, 1] };
}
return { min, max };
}
export function centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
const center = new THREE.Vector3(
(toFiniteNumber(min[0]) + toFiniteNumber(max[0], 1)) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1], 1)) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2], 1)) / 2
);
const size = new THREE.Vector3(
Math.max(toFiniteNumber(max[0], 1) - toFiniteNumber(min[0]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[1], 1) - toFiniteNumber(min[1]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[2], 1) - toFiniteNumber(min[2]), settings.minBoundsSpan)
);
return {
center,
radius: Math.max(size.length() / 2, settings.minModelRadius),
size
};
}
export function colorTextureFromBackground(background, width, height) {
if (background.type === "solid") {
return new THREE.Color(background.solidColor);
}
const canvas = document.createElement("canvas");
canvas.width = Math.max(2, width);
canvas.height = Math.max(2, height);
const context = canvas.getContext("2d");
if (background.type === "radial") {
const gradient = context.createRadialGradient(
canvas.width / 2,
canvas.height / 2,
0,
canvas.width / 2,
canvas.height / 2,
Math.max(canvas.width, canvas.height) / 1.35
);
gradient.addColorStop(0, background.radialInner);
gradient.addColorStop(1, background.radialOuter);
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
} else {
const angle = ((toFiniteNumber(background.linearAngle, 135) - 90) * Math.PI) / 180;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const length = Math.hypot(canvas.width, canvas.height);
const dx = Math.cos(angle) * length / 2;
const dy = Math.sin(angle) * length / 2;
const gradient = context.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy);
gradient.addColorStop(0, background.linearStart);
gradient.addColorStop(1, background.linearEnd);
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
export async function applyEnvironment(scene, themeSettings, warnings = []) {
const environment = themeSettings.environment || {};
if (!environment.enabled) {
scene.environment = null;
return;
}
const preset = getEnvironmentPresetById(environment.presetId);
const textureUrl = String(preset?.url || "").trim();
if (!textureUrl) {
return;
}
try {
const loader = new THREE.TextureLoader();
if (typeof loader.setCrossOrigin === "function") {
loader.setCrossOrigin("anonymous");
}
const texture = await loader.loadAsync(textureUrl);
texture.mapping = THREE.EquirectangularReflectionMapping;
texture.colorSpace = THREE.SRGBColorSpace;
scene.environment = texture;
if (scene.environmentRotation?.set) {
scene.environmentRotation.set(0, toFiniteNumber(environment.rotationY), 0);
}
if (environment.useAsBackground) {
scene.background = texture;
}
} catch {
warnings.push(`Environment preset unavailable: ${preset?.label || environment.presetId || "default"}`);
}
}
export function applyLighting(scene, themeSettings) {
const lighting = themeSettings.lighting || {};
const addIfEnabled = (light, enabled) => {
light.visible = enabled;
scene.add(light);
};
addIfEnabled(
new THREE.HemisphereLight(
lighting.hemisphere?.skyColor || "#ffffff",
lighting.hemisphere?.groundColor || "#101014",
toFiniteNumber(lighting.hemisphere?.intensity, 1)
),
lighting.hemisphere?.enabled !== false
);
addIfEnabled(
new THREE.AmbientLight(lighting.ambient?.color || "#ffffff", toFiniteNumber(lighting.ambient?.intensity, 0.2)),
lighting.ambient?.enabled !== false
);
const directional = new THREE.DirectionalLight(
lighting.directional?.color || "#ffffff",
toFiniteNumber(lighting.directional?.intensity, 1)
);
directional.position.set(
toFiniteNumber(lighting.directional?.position?.x, 160),
toFiniteNumber(lighting.directional?.position?.y, -140),
toFiniteNumber(lighting.directional?.position?.z, 240)
);
directional.castShadow = true;
addIfEnabled(directional, lighting.directional?.enabled !== false);
const spot = new THREE.SpotLight(
lighting.spot?.color || "#ffffff",
toFiniteNumber(lighting.spot?.intensity, 0),
toFiniteNumber(lighting.spot?.distance, 0),
toFiniteNumber(lighting.spot?.angle, Math.PI / 6)
);
spot.position.set(
toFiniteNumber(lighting.spot?.position?.x, 160),
toFiniteNumber(lighting.spot?.position?.y, -120),
toFiniteNumber(lighting.spot?.position?.z, 140)
);
addIfEnabled(spot, lighting.spot?.enabled === true);
scene.add(spot.target);
const point = new THREE.PointLight(
lighting.point?.color || "#ffffff",
toFiniteNumber(lighting.point?.intensity, 0),
toFiniteNumber(lighting.point?.distance, 0)
);
point.position.set(
toFiniteNumber(lighting.point?.position?.x, -120),
toFiniteNumber(lighting.point?.position?.y, 80),
toFiniteNumber(lighting.point?.position?.z, 140)
);
addIfEnabled(point, lighting.point?.enabled === true);
}
export function addFloor(scene, bounds, themeSettings, sceneScale, settingsByScale) {
const floor = themeSettings.floor || {};
const mode = floor.mode || THEME_FLOOR_MODES.STAGE;
const floorEnabled = floor.enabled === true || (
!Object.hasOwn(floor, "enabled")
&& mode !== THEME_FLOOR_MODES.NONE
&& mode !== THEME_FLOOR_MODES.GRID
);
const gridSettings = floor.grid && typeof floor.grid === "object" && !Array.isArray(floor.grid)
? floor.grid
: {};
const gridEnabled = gridSettings.enabled === true || mode === THEME_FLOOR_MODES.GRID;
if (!floorEnabled && !gridEnabled) {
return;
}
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const { center, radius } = centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale);
const minZ = Array.isArray(bounds?.min) ? toFiniteNumber(bounds.min[2]) : 0;
const gridSize = Math.max(radius * 3, settings.minFloorSize);
if (gridEnabled) {
const gridDensity = clamp(
toFiniteNumber(gridSettings.density ?? floor.gridDensity, DEFAULT_FLOOR_GRID_SETTINGS.gridDensity),
MIN_FLOOR_GRID_DENSITY,
MAX_FLOOR_GRID_DENSITY
);
const grid = new THREE.GridHelper(
gridSize,
Math.max(8, Math.round(28 * gridDensity)),
gridSettings.centerColor || floor.gridCenterColor || floor.color || DEFAULT_FLOOR_GRID_SETTINGS.gridCenterColor,
gridSettings.cellColor || floor.gridCellColor || floor.color || DEFAULT_FLOOR_GRID_SETTINGS.gridCellColor
);
const materials = Array.isArray(grid.material) ? grid.material : [grid.material];
for (const material of materials) {
material.transparent = true;
material.opacity = clamp(
toFiniteNumber(gridSettings.opacity ?? floor.gridOpacity, DEFAULT_FLOOR_GRID_SETTINGS.gridOpacity),
0,
1
);
material.depthWrite = false;
material.toneMapped = false;
}
grid.rotation.x = Math.PI / 2;
grid.position.set(center.x, center.y, minZ - 0.02);
scene.add(grid);
}
if (!floorEnabled) {
return;
}
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(gridSize, gridSize),
new THREE.ShadowMaterial({
color: floor.color || "#f1f5f9",
opacity: clamp(toFiniteNumber(floor.shadowOpacity, 0.24), 0, 1)
})
);
plane.rotation.x = 0;
plane.position.set(center.x, center.y, minZ - 0.03);
plane.receiveShadow = true;
scene.add(plane);
}
export function boundsCorners(bounds) {
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
return [
[min[0], min[1], min[2]],
[min[0], min[1], max[2]],
[min[0], max[1], min[2]],
[min[0], max[1], max[2]],
[max[0], min[1], min[2]],
[max[0], min[1], max[2]],
[max[0], max[1], min[2]],
[max[0], max[1], max[2]]
].map((corner) => new THREE.Vector3(
toFiniteNumber(corner[0]),
toFiniteNumber(corner[1]),
toFiniteNumber(corner[2])
));
}
export function framePadding(job = {}) {
const rawPadding = job.render?.padding ?? job.render?.paddingPercent ?? job.padding ?? job.paddingPercent;
return clamp(toFiniteNumber(rawPadding, 0.04), 0, 0.15);
}
export function frameHalfHeightForView(view, bounds, width, height, padding, sceneScale, settingsByScale) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const resolvedView = resolveRenderView(view, RENDER_VIEW_PRESETS, { strict: false });
const direction = new THREE.Vector3(...resolvedView.direction).normalize();
const up = new THREE.Vector3(...resolvedView.up).normalize();
const right = new THREE.Vector3().crossVectors(direction, up).normalize();
const screenUp = new THREE.Vector3().crossVectors(right, direction).normalize();
const corners = boundsCorners(bounds);
const xs = corners.map((corner) => corner.dot(right));
const ys = corners.map((corner) => corner.dot(screenUp));
const spanX = Math.max(Math.max(...xs) - Math.min(...xs), settings.minBoundsSpan);
const spanY = Math.max(Math.max(...ys) - Math.min(...ys), settings.minBoundsSpan);
const aspect = Math.max(width / Math.max(height, 1), 0.01);
const safeContentScale = Math.max(1 - (padding * 2), 0.1);
return Math.max(
spanY / (2 * safeContentScale),
spanX / (2 * aspect * safeContentScale),
settings.minBoundsSpan / 2
) / normalizeCameraZoom(resolvedView.zoom, 1);
}
export function fitOrthographicCamera(camera, view, bounds, width, height, {
lockedHalfHeight = null,
padding = 0.12,
sceneScale = RENDER_SCENE_SCALE.CAD,
settingsByScale
} = {}) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const resolvedCamera = resolveCameraSnapshot(view, bounds, {
sceneScale,
settingsByScale,
strict: false
});
const target = new THREE.Vector3(...resolvedCamera.target);
camera.position.set(...resolvedCamera.position);
camera.up.set(...resolvedCamera.up);
camera.lookAt(target);
const aspect = Math.max(width / Math.max(height, 1), 0.01);
const halfHeight = lockedHalfHeight || frameHalfHeightForView(resolvedCamera.view, bounds, width, height, padding, sceneScale, settingsByScale);
camera.top = halfHeight;
camera.bottom = -halfHeight;
camera.left = -halfHeight * aspect;
camera.right = halfHeight * aspect;
camera.near = 0.01;
camera.far = Math.max(resolvedCamera.radius * 12, settings.minCameraFar);
camera.updateProjectionMatrix();
return resolvedCamera;
}
export function fitPerspectiveCamera(camera, cameraSpec, bounds, width, height, {
sceneScale = RENDER_SCENE_SCALE.CAD,
settingsByScale,
strict = true
} = {}) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const resolvedCamera = resolveCameraSnapshot(cameraSpec, bounds, {
sceneScale,
settingsByScale,
strict
});
camera.aspect = Math.max(width / Math.max(height, 1), 0.01);
camera.position.set(...resolvedCamera.position);
camera.up.set(...resolvedCamera.up);
camera.zoom = normalizeCameraZoom(resolvedCamera.zoom, 1);
camera.near = Math.max(resolvedCamera.radius / 1200, 0.01);
camera.far = Math.max(resolvedCamera.radius * 600, settings.minCameraFar, 2000);
camera.lookAt(new THREE.Vector3(...resolvedCamera.target));
camera.updateProjectionMatrix();
return resolvedCamera;
}
export function lockedFrameHalfHeight(outputs, bounds, width, height, job, sceneScale, settingsByScale) {
const padding = framePadding(job);
return Math.max(
...outputs.map((output) => {
const outputWidth = toFiniteNumber(output.width, width);
const outputHeight = toFiniteNumber(output.height, height);
return frameHalfHeightForView(resolveRenderView(output.camera || job.camera || "iso"), bounds, outputWidth, outputHeight, padding, sceneScale, settingsByScale);
}),
renderSceneScaleSettings(sceneScale, settingsByScale).minBoundsSpan / 2
);
}
export function outputSize(output, job) {
return {
width: Math.max(1, Math.floor(toFiniteNumber(output.width, job.width || 1400))),
height: Math.max(1, Math.floor(toFiniteNumber(output.height, job.height || 900)))
};
}
export function configurePngRenderer(width, height, job, themeSettings, {
defaultRenderScale = 1
} = {}) {
const renderer = createCadWebGlRenderer(THREE, {
preserveDrawingBuffer: true
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = Math.max(toFiniteNumber(themeSettings.lighting?.toneMappingExposure, 1), 0.05);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setPixelRatio(clamp(toFiniteNumber(job.render?.renderScale, job.renderScale || defaultRenderScale), 1, 3));
renderer.setSize(width, height, false);
document.body.innerHTML = "";
document.body.appendChild(renderer.domElement);
return renderer;
}
export function dataUrlFromRenderer(renderer, mime = "image/png") {
return renderer.domElement.toDataURL(mime);
}
export function shouldBurnInViewLabels(job = {}) {
return typeof job.render?.viewLabels === "boolean"
? job.render.viewLabels
: typeof job.viewLabels === "boolean"
? job.viewLabels
: false;
}
export function drawBurnedInLabel(context, label, width, height, {
corner = "top-left",
fill = "#111827",
background = "rgba(255, 255, 255, 0.9)",
border = "rgba(17, 24, 39, 0.42)"
} = {}) {
const text = String(label || "").trim();
if (!text) {
return;
}
const safeWidth = Math.max(width, 1);
const safeHeight = Math.max(height, 1);
const fontSize = Math.max(18, Math.min(Math.round(safeWidth * 0.018), 32));
const padX = Math.round(fontSize * 0.72);
const padY = Math.round(fontSize * 0.42);
context.save();
context.font = `700 ${fontSize}px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`;
context.textBaseline = "top";
const metrics = context.measureText(text);
const boxWidth = Math.ceil(metrics.width + padX * 2);
const boxHeight = Math.ceil(fontSize + padY * 2);
const margin = Math.max(18, Math.round(Math.min(safeWidth, safeHeight) * 0.024));
const x = corner.includes("right") ? safeWidth - margin - boxWidth : margin;
const y = corner.includes("bottom") ? safeHeight - margin - boxHeight : margin;
context.fillStyle = background;
context.strokeStyle = border;
context.lineWidth = Math.max(1, Math.round(fontSize * 0.06));
context.beginPath();
context.roundRect(x, y, boxWidth, boxHeight, Math.round(fontSize * 0.28));
context.fill();
context.stroke();
context.fillStyle = fill;
context.fillText(text, x + padX, y + padY);
context.restore();
}
export function rendererDataUrlWithOptionalLabel(renderer, label, job) {
if (!shouldBurnInViewLabels(job)) {
return dataUrlFromRenderer(renderer);
}
const source = renderer.domElement;
const canvas = document.createElement("canvas");
canvas.width = source.width;
canvas.height = source.height;
const context = canvas.getContext("2d");
context.drawImage(source, 0, 0);
drawBurnedInLabel(context, label, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
}
@@ -0,0 +1,224 @@
import {
assemblyUsesSelfContainedMesh,
buildSelfContainedAssemblyMeshData
} from "../lib/assembly/meshData.js";
import { buildMeshDataFromGlbBuffer } from "../lib/render/glbMeshData.js";
import {
loadRenderDisplayEdgeBundle,
loadRenderGlb,
loadRenderSelectorBundle,
loadRenderTopologyIndex
} from "../lib/stepRenderAssetClient.js";
import {
buildDisplayEdgeRuntime,
buildSelectorRuntime
} from "../lib/selectors/runtime.js";
import {
loadStepModuleDefinition
} from "./stepModule.js";
import {
hasStepParameterRenderValues,
normalizeStepParameterRenderValues,
stepParameterRenderFrameState,
stepParameterRenderFrameValues
} from "./stepParameters.js";
export const SOURCE_KIND = Object.freeze({
STEP: "step",
STP: "stp",
GLB: "glb",
STL: "stl",
THREE_MF: "3mf",
UNKNOWN: "unknown"
});
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeKind(value = "") {
const kind = String(value || "").trim().toLowerCase();
if (kind === "step" || kind === "stp" || kind === "glb" || kind === "stl" || kind === "3mf") {
return kind;
}
return SOURCE_KIND.UNKNOWN;
}
function sourceKindFromUrl(url = "") {
const pathname = String(url || "").split(/[?#]/, 1)[0].toLowerCase();
if (pathname.endsWith(".step")) {
return SOURCE_KIND.STEP;
}
if (pathname.endsWith(".stp")) {
return SOURCE_KIND.STP;
}
if (pathname.endsWith(".glb") || pathname.endsWith(".gltf")) {
return SOURCE_KIND.GLB;
}
if (pathname.endsWith(".stl")) {
return SOURCE_KIND.STL;
}
if (pathname.endsWith(".3mf")) {
return SOURCE_KIND.THREE_MF;
}
return SOURCE_KIND.UNKNOWN;
}
export function sourceIsStep(sourceOrKind) {
const kind = typeof sourceOrKind === "string" ? sourceOrKind : sourceOrKind?.kind;
const normalized = normalizeKind(kind);
return normalized === SOURCE_KIND.STEP || normalized === SOURCE_KIND.STP;
}
function assertStepOnlyOption(kind, value, label) {
if (value === undefined || value === null) {
return;
}
if (!sourceIsStep(kind)) {
throw new Error(`${label} is supported only for STEP/STP sources`);
}
}
async function loadStepMeshFromGlb(glbUrl) {
const meshData = await loadRenderGlb(glbUrl);
const topology = await loadRenderTopologyIndex(glbUrl);
if (assemblyUsesSelfContainedMesh(topology)) {
return buildSelfContainedAssemblyMeshData(topology, meshData);
}
return meshData;
}
async function loadSelectorRuntime(glbUrl, { cadPath = "" } = {}) {
if (!glbUrl) {
return null;
}
try {
const selectorBundle = await loadRenderSelectorBundle(glbUrl);
return buildSelectorRuntime(selectorBundle, {
copyCadPath: cadPath
});
} catch {
return null;
}
}
async function loadDisplayEdgeRuntime(glbUrl) {
if (!glbUrl) {
return null;
}
try {
return buildDisplayEdgeRuntime(await loadRenderDisplayEdgeBundle(glbUrl));
} catch {
return null;
}
}
async function loadMeshDataFromUrl(url, kind) {
if (sourceIsStep(kind)) {
return loadStepMeshFromGlb(url);
}
if (kind === SOURCE_KIND.GLB) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load GLB source: HTTP ${response.status}`);
}
return buildMeshDataFromGlbBuffer(await response.arrayBuffer());
}
throw new Error(`Unsupported render source kind: ${kind || SOURCE_KIND.UNKNOWN}`);
}
async function loadStepParameters({
kind,
stepParameters,
stepParameterUrl,
cadPath,
selectorRuntime
}) {
assertStepOnlyOption(kind, stepParameters, "stepParameters");
assertStepOnlyOption(kind, stepParameterUrl, "stepParameterUrl");
const explicit = hasStepParameterRenderValues(stepParameters);
if (!stepParameterUrl) {
if (!explicit) {
return null;
}
throw new Error("STEP render parameters require resolved.stepParameterUrl");
}
const definition = await loadStepModuleDefinition(stepParameterUrl, { cadPath });
const renderParameters = normalizeStepParameterRenderValues(definition, explicit ? stepParameters : {});
return {
definition,
renderParameters,
selectorRuntime,
cadPath: cadPath || definition.cadPath || "",
sourceUrl: stepParameterUrl
};
}
export function stepParameterFrameRuntime(stepParameterSource, frameIndex = 0) {
if (!stepParameterSource) {
return null;
}
const { definition, renderParameters } = stepParameterSource;
return {
definition,
selectorRuntime: stepParameterSource.selectorRuntime || null,
parameterValues: stepParameterRenderFrameValues(definition, renderParameters, frameIndex),
animationState: stepParameterRenderFrameState(renderParameters, frameIndex),
cadPath: stepParameterSource.cadPath || definition.cadPath || "",
sourceUrl: stepParameterSource.sourceUrl || definition.url || ""
};
}
export async function loadSource(input, options = {}) {
const inputObject = isObject(input) ? input : {};
if (Object.prototype.hasOwnProperty.call(inputObject, "params")) {
throw new Error("Render source jobs use stepParameters; params is no longer a shared render API field");
}
const resolved = isObject(inputObject.resolved) ? inputObject.resolved : {};
const explicitMeshData = inputObject.meshData || options.meshData || (
inputObject.vertices && inputObject.indices ? inputObject : null
);
const rawKind = inputObject.kind || resolved.kind || options.kind || (
typeof input === "string" ? sourceKindFromUrl(input) : ""
);
const kind = normalizeKind(rawKind);
const stepParameters = inputObject.stepParameters ?? options.stepParameters;
const stepParameterUrl = String(
inputObject.stepParameterUrl || resolved.stepParameterUrl || options.stepParameterUrl || ""
).trim();
const cadPath = String(inputObject.cadPath || resolved.inputPath || options.cadPath || "").trim();
assertStepOnlyOption(kind, stepParameters, "stepParameters");
assertStepOnlyOption(kind, stepParameterUrl, "stepParameterUrl");
let meshData = explicitMeshData;
const glbUrl = String(inputObject.glbUrl || resolved.glbUrl || options.glbUrl || "").trim();
const url = String(typeof input === "string" ? input : inputObject.url || resolved.url || glbUrl || "").trim();
if (!meshData) {
if (!url) {
throw new Error("loadSource requires meshData, a source URL, or resolved.glbUrl");
}
meshData = await loadMeshDataFromUrl(sourceIsStep(kind) ? glbUrl || url : url, kind);
}
const selectorRuntime = inputObject.selectorRuntime || options.selectorRuntime || await loadSelectorRuntime(glbUrl || url, { cadPath });
const displayEdgeRuntime = inputObject.displayEdgeRuntime || options.displayEdgeRuntime || await loadDisplayEdgeRuntime(glbUrl || url);
const stepParameterSource = await loadStepParameters({
kind,
stepParameters,
stepParameterUrl,
cadPath,
selectorRuntime
});
return {
kind,
meshData,
selectorRuntime,
displayEdgeRuntime,
stepParameterSource,
resolved,
url,
glbUrl,
cadPath
};
}
@@ -0,0 +1,512 @@
import {
buildCadRefToken,
normalizeCadPath,
normalizeCadRefSelectors,
parseCadRefSelector,
parseCadRefToken
} from "../lib/cadRefs.js";
export const STEP_MODULE_SCHEMA_VERSION = 1;
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeString(value, fallback = "") {
const text = String(value ?? fallback).trim();
return text || fallback;
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeParameterType(value) {
const type = normalizeString(value, "number").toLowerCase();
if (["number", "boolean", "enum", "select", "color", "string", "button"].includes(type)) {
return type === "select" ? "enum" : type;
}
return "number";
}
function normalizeParameterOptions(value) {
return (Array.isArray(value) ? value : [])
.map((option) => {
if (isObject(option)) {
const valueText = normalizeString(option.value);
return valueText ? {
value: valueText,
label: normalizeString(option.label, valueText)
} : null;
}
const valueText = normalizeString(option);
return valueText ? { value: valueText, label: valueText } : null;
})
.filter(Boolean);
}
function normalizeParameterDefinition(id, rawDefinition) {
const raw = isObject(rawDefinition) ? rawDefinition : {};
const type = normalizeParameterType(raw.type);
const min = toFiniteNumber(raw.min, 0);
const max = Math.max(toFiniteNumber(raw.max, type === "number" ? 1 : min), min);
const options = normalizeParameterOptions(raw.options || raw.values);
const fallbackDefault = type === "boolean"
? false
: type === "color"
? "#ffffff"
: type === "enum"
? options[0]?.value || ""
: type === "string"
? ""
: 0;
return {
id,
type,
label: normalizeString(raw.label, id),
description: normalizeString(raw.description),
unit: normalizeString(raw.unit),
min,
max,
step: Math.max(toFiniteNumber(raw.step, type === "number" ? 0.01 : 1), 0),
defaultValue: normalizeParameterValue({ type, min, max, options }, raw.default ?? raw.defaultValue ?? fallbackDefault),
options
};
}
export function normalizeParameterValue(definition, value) {
const type = normalizeParameterType(definition?.type);
if (type === "boolean") {
return value === true;
}
if (type === "color") {
const color = normalizeString(value, normalizeString(definition?.defaultValue, "#ffffff"));
return HEX_COLOR_RE.test(color) ? color : "#ffffff";
}
if (type === "enum") {
const options = Array.isArray(definition?.options) ? definition.options : [];
const valueText = normalizeString(value, options[0]?.value || "");
return options.some((option) => option.value === valueText) ? valueText : (options[0]?.value || "");
}
if (type === "string") {
return String(value ?? "");
}
if (type === "button") {
return Math.max(0, Math.floor(toFiniteNumber(value, 0)));
}
const min = toFiniteNumber(definition?.min, 0);
const max = Math.max(toFiniteNumber(definition?.max, min), min);
return clamp(toFiniteNumber(value, toFiniteNumber(definition?.defaultValue, min)), min, max);
}
function normalizeParameters(value) {
return Object.entries(isObject(value) ? value : {})
.map(([id, rawDefinition]) => normalizeParameterDefinition(normalizeString(id), rawDefinition))
.filter((definition) => definition.id);
}
function inferCadPathFromStepModuleUrl(url) {
let pathname = String(url || "").split("?")[0].split("#")[0].replace(/\\/g, "/").trim();
if (!pathname) {
return "";
}
try {
pathname = new URL(pathname, "http://localhost").pathname;
} catch {
// Keep the raw path for relative module URLs.
}
const normalizedPath = pathname.replace(/^\/+|\/+$/g, "");
const slashIndex = normalizedPath.lastIndexOf("/");
const directory = slashIndex >= 0 ? normalizedPath.slice(0, slashIndex) : "";
const fileName = slashIndex >= 0 ? normalizedPath.slice(slashIndex + 1) : normalizedPath;
const match = fileName.match(/^\.?(.+)\.step\.js$/);
if (!match?.[1]) {
return "";
}
return normalizeCadPath([directory, match[1]].filter(Boolean).join("/"));
}
function normalizeRelativeStepPath(value) {
const rawPath = normalizeString(value).replace(/\\/g, "/");
if (!rawPath || rawPath.startsWith("/") || /^[A-Za-z]:\//.test(rawPath) || /^[a-z][a-z0-9+.-]*:/i.test(rawPath)) {
return "";
}
const parts = rawPath.split("/").filter((part) => part && part !== ".");
if (!parts.length || parts.some((part) => part === "..")) {
return "";
}
const normalizedPath = parts.join("/");
return /\.(step|stp)$/i.test(normalizedPath) ? normalizedPath : "";
}
function cadPathFromStepPath(stepPath) {
return normalizeCadPath(String(stepPath || "").replace(/\.(step|stp)$/i, ""));
}
function normalizeStepLink(value) {
const raw = isObject(value) ? value : {};
return {
...raw,
path: normalizeRelativeStepPath(raw.path)
};
}
function normalizeManifestStep(value, normalizedStep) {
if (!isObject(value) && !normalizedStep.path) {
return null;
}
const step = isObject(value) ? { ...value } : {};
if (normalizedStep.path) {
step.path = normalizedStep.path;
} else {
delete step.path;
}
return Object.keys(step).length ? step : null;
}
function normalizeFeatureRef(rawFeature, cadPath = "") {
const raw = isObject(rawFeature) ? rawFeature : {};
const ref = normalizeString(raw.ref);
void cadPath;
const fullToken = parseCadRefToken(ref);
if (fullToken) {
return buildCadRefToken({ selectors: fullToken.selectors });
}
const rawSelectors = ref.startsWith("#") ? ref.slice(1) : "";
const selectors = normalizeCadRefSelectors(
rawSelectors ||
raw.selector ||
raw.selectors ||
raw.occurrence ||
raw.occurrences ||
[]
);
return selectors.length
? buildCadRefToken({ selectors })
: ref;
}
function normalizeFeatures(value, { cadPath = "" } = {}) {
return Object.entries(isObject(value) ? value : {})
.map(([id, rawFeature]) => {
const raw = isObject(rawFeature) ? rawFeature : {};
const featureId = normalizeString(id);
return featureId ? {
id: featureId,
label: normalizeString(raw.label, featureId),
description: normalizeString(raw.description),
ref: normalizeFeatureRef(raw, cadPath),
partIds: Array.isArray(raw.partIds)
? raw.partIds.map((partId) => normalizeString(partId)).filter(Boolean)
: [],
axis: normalizeVector3(raw.axis, [0, 0, 1]),
origin: normalizePoint3(raw.origin, null)
} : null;
})
.filter(Boolean);
}
function normalizeAnimations(value) {
return Object.entries(isObject(value) ? value : {})
.map(([id, rawAnimation]) => {
const raw = isObject(rawAnimation) ? rawAnimation : {};
const animationId = normalizeString(id);
return animationId ? {
id: animationId,
label: normalizeString(raw.label, animationId),
description: normalizeString(raw.description),
duration: Math.max(toFiniteNumber(raw.duration ?? raw.durationSeconds, 1), 0.001),
loop: raw.loop !== false,
update: typeof raw.update === "function" ? raw.update : null,
raw
} : null;
})
.filter(Boolean);
}
function normalizeVector3(value, fallback = [0, 0, 0]) {
const source = Array.isArray(value) ? value : fallback;
return [
toFiniteNumber(source?.[0], fallback[0] || 0),
toFiniteNumber(source?.[1], fallback[1] || 0),
toFiniteNumber(source?.[2], fallback[2] || 0)
];
}
function normalizePoint3(value, fallback = [0, 0, 0]) {
if (!Array.isArray(value) && fallback === null) {
return null;
}
return normalizeVector3(value, fallback || [0, 0, 0]);
}
export function normalizeStepModuleDefinition(rawModule, { url = "", cadPath = "" } = {}) {
if (!isObject(rawModule)) {
throw new Error("STEP runtime module must export an object");
}
const manifest = isObject(rawModule.manifest) ? rawModule.manifest : rawModule;
const schemaVersion = Number(manifest.schemaVersion || rawModule.schemaVersion || 1);
if (schemaVersion !== STEP_MODULE_SCHEMA_VERSION) {
throw new Error(`Unsupported STEP runtime module schemaVersion ${schemaVersion || "unknown"}`);
}
const step = normalizeStepLink(manifest.step);
const stepCadPath = cadPathFromStepPath(step.path);
const normalizedCadPath = stepCadPath || normalizeCadPath(cadPath) || inferCadPathFromStepModuleUrl(url);
const parameters = normalizeParameters(manifest.parameters);
const parameterMap = Object.fromEntries(parameters.map((definition) => [definition.id, definition]));
const defaultParameterValues = Object.fromEntries(
parameters.map((definition) => [definition.id, definition.defaultValue])
);
const normalizedManifestStep = normalizeManifestStep(manifest.step, step);
const normalizedManifest = {
...manifest,
schemaVersion: STEP_MODULE_SCHEMA_VERSION
};
if (normalizedManifestStep) {
normalizedManifest.step = normalizedManifestStep;
} else {
delete normalizedManifest.step;
}
return {
url: normalizeString(url),
cadPath: normalizedCadPath,
step: {
path: step.path,
cadPath: stepCadPath || normalizedCadPath,
explicit: Boolean(step.path),
inferred: !step.path && Boolean(normalizedCadPath)
},
module: rawModule,
manifest: normalizedManifest,
features: normalizeFeatures(manifest.features, { cadPath: normalizedCadPath }),
parameters,
parameterMap,
defaultParameterValues,
animations: normalizeAnimations(manifest.animations)
};
}
export async function loadStepModuleDefinition(url, options = {}) {
const namespace = await import(/* webpackIgnore: true */ /* @vite-ignore */ url);
return normalizeStepModuleDefinition(namespace?.default ?? namespace, { url, ...options });
}
export function normalizeStepModuleParameterValues(definition, values = {}) {
const parameterMap = definition?.parameterMap || {};
return Object.fromEntries(
Object.values(parameterMap).map((parameter) => [
parameter.id,
normalizeParameterValue(parameter, isObject(values) && Object.hasOwn(values, parameter.id)
? values[parameter.id]
: parameter.defaultValue)
])
);
}
const STEP_MODULE_ZERO_STATE_EPSILON = 1e-6;
function stepModuleValueMatchesZeroState(parameter, value, zeroValue) {
const type = normalizeParameterType(parameter?.type);
if (type === "number" || type === "button") {
const numericValue = Number(value);
const numericZeroValue = Number(zeroValue);
return Number.isFinite(numericValue) &&
Number.isFinite(numericZeroValue) &&
Math.abs(numericValue - numericZeroValue) <= STEP_MODULE_ZERO_STATE_EPSILON;
}
if (type === "color") {
return String(value || "").toLowerCase() === String(zeroValue || "").toLowerCase();
}
return value === zeroValue;
}
export function stepModuleParameterValuesAtZeroState(definition, values = {}) {
const parameters = Array.isArray(definition?.parameters) ? definition.parameters : [];
if (!parameters.length) {
return true;
}
const normalizedValues = normalizeStepModuleParameterValues(definition, values);
const zeroValues = normalizeStepModuleParameterValues(definition, definition?.defaultParameterValues || {});
return parameters.every((parameter) => (
stepModuleValueMatchesZeroState(parameter, normalizedValues[parameter.id], zeroValues[parameter.id])
));
}
export function stepModuleAnimationStateAtZeroState(animationState = {}) {
return animationState?.playing !== true &&
Math.abs(toFiniteNumber(animationState?.elapsedSec, 0)) <= STEP_MODULE_ZERO_STATE_EPSILON;
}
export function stepModuleRuntimeAtZeroState(definition, runtimeState = {}) {
return stepModuleParameterValuesAtZeroState(definition, runtimeState?.parameterValues) &&
stepModuleAnimationStateAtZeroState(runtimeState?.animationState);
}
function boundsCenter(bounds) {
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : min;
return [
(toFiniteNumber(min[0]) + toFiniteNumber(max[0])) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1])) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2])) / 2
];
}
function mergeBounds(boundsList) {
const validBounds = (Array.isArray(boundsList) ? boundsList : [])
.filter((bounds) => Array.isArray(bounds?.min) && Array.isArray(bounds?.max));
if (!validBounds.length) {
return null;
}
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const bounds of validBounds) {
for (let index = 0; index < 3; index += 1) {
min[index] = Math.min(min[index], toFiniteNumber(bounds.min[index]));
max[index] = Math.max(max[index], toFiniteNumber(bounds.max[index]));
}
}
return { min, max };
}
function partIdsForSelector(selector, meshData) {
const normalizedSelector = normalizeString(selector);
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
if (!normalizedSelector || !parts.length) {
return [];
}
return parts
.filter((part) => {
const ids = [part?.id, part?.occurrenceId]
.map((id) => normalizeString(id))
.filter(Boolean);
return ids.some((id) => (
id === normalizedSelector ||
id.startsWith(`${normalizedSelector}.`) ||
normalizedSelector.startsWith(`${id}.`)
));
})
.map((part) => normalizeString(part.id || part.occurrenceId))
.filter(Boolean);
}
function referenceForSelector(selector, selectorRuntime) {
const normalizedSelector = normalizeString(selector);
if (!normalizedSelector || !selectorRuntime) {
return null;
}
return selectorRuntime.referenceByNormalizedSelector?.get(normalizedSelector)
|| selectorRuntime.referenceByDisplaySelector?.get(normalizedSelector)
|| selectorRuntime.referenceMap?.get(normalizedSelector)
|| null;
}
function uniqueStrings(values) {
return [...new Set((Array.isArray(values) ? values : [])
.map((value) => normalizeString(value))
.filter(Boolean))];
}
function featureSelectors(feature) {
const token = parseCadRefToken(feature?.ref);
if (!token) {
return [];
}
return token.selectors.length ? token.selectors : ["__model__"];
}
export function resolveStepModuleFeatures(definition, {
meshData = null,
selectorRuntime = null
} = {}) {
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
const partMap = new Map(parts.map((part) => [normalizeString(part.id || part.occurrenceId), part]));
const allPartIds = parts.length
? parts.map((part) => normalizeString(part.id || part.occurrenceId)).filter(Boolean)
: ["__model__"];
return Object.fromEntries(
(Array.isArray(definition?.features) ? definition.features : []).map((feature) => {
const selectors = featureSelectors(feature);
const references = selectors
.map((selector) => selector === "__model__" ? null : referenceForSelector(selector, selectorRuntime))
.filter(Boolean);
const selectorPartIds = selectors.flatMap((selector) => {
if (selector === "__model__") {
return allPartIds;
}
const parsedSelector = parseCadRefSelector(selector);
if (parsedSelector?.occurrenceId) {
return partIdsForSelector(parsedSelector.occurrenceId, meshData);
}
const reference = referenceForSelector(selector, selectorRuntime);
return partIdsForSelector(reference?.partId || reference?.occurrenceId || selector, meshData);
});
const partIds = uniqueStrings([
...feature.partIds,
...selectorPartIds
]);
const partsForFeature = partIds.map((partId) => partMap.get(partId)).filter(Boolean);
const bounds = mergeBounds(partsForFeature.map((part) => part.bounds)) || meshData?.bounds || null;
return [feature.id, {
...feature,
selectors,
references,
partIds,
parts: partsForFeature,
bounds,
center: feature.origin || boundsCenter(bounds),
transform: partsForFeature.length === 1 ? partsForFeature[0]?.transform || null : null,
transforms: partsForFeature.map((part) => part?.transform || null),
missing: selectors.length > 0 && partIds.length === 0 && selectors[0] !== "__model__"
}];
})
);
}
export function stepModuleTargetPartIds(target, features, meshData) {
if (Array.isArray(target)) {
return uniqueStrings(target.flatMap((item) => stepModuleTargetPartIds(item, features, meshData)));
}
if (isObject(target)) {
if (Array.isArray(target.partIds)) {
return uniqueStrings(target.partIds);
}
if (target.partId) {
return uniqueStrings([target.partId]);
}
if (target.feature) {
return stepModuleTargetPartIds(target.feature, features, meshData);
}
if (target.ref) {
return stepModuleTargetPartIds(target.ref, features, meshData);
}
}
const normalizedTarget = normalizeString(target);
if (!normalizedTarget) {
return [];
}
if (normalizedTarget === "*" || normalizedTarget === "all" || normalizedTarget === "__all__") {
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
return parts.length
? uniqueStrings(parts.map((part) => part?.id || part?.occurrenceId))
: ["__model__"];
}
const feature = features?.[normalizedTarget];
if (feature) {
return uniqueStrings(feature.partIds);
}
const token = parseCadRefToken(normalizedTarget);
if (token) {
const selectors = token.selectors.length ? token.selectors : ["*"];
return uniqueStrings(selectors.flatMap((selector) => stepModuleTargetPartIds(selector, features, meshData)));
}
return uniqueStrings(partIdsForSelector(normalizedTarget, meshData));
}
@@ -0,0 +1,323 @@
import { stepModuleTargetPartIds } from "./stepModule.js";
function toNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function uniqueStrings(values) {
return [...new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || "").trim())
.filter(Boolean))];
}
export function buildPartTransformMatrix(THREE, transform) {
const matrix = new THREE.Matrix4();
if (!Array.isArray(transform) || transform.length !== 16) {
matrix.identity();
return matrix;
}
matrix.set(
Number(transform[0]) || 0,
Number(transform[1]) || 0,
Number(transform[2]) || 0,
Number(transform[3]) || 0,
Number(transform[4]) || 0,
Number(transform[5]) || 0,
Number(transform[6]) || 0,
Number(transform[7]) || 0,
Number(transform[8]) || 0,
Number(transform[9]) || 0,
Number(transform[10]) || 0,
Number(transform[11]) || 0,
Number(transform[12]) || 0,
Number(transform[13]) || 0,
Number(transform[14]) || 0,
Number(transform[15]) || 0
);
return matrix;
}
export function displayTransformForPart(meshData, part, renderPartsIndividually = true) {
if (!renderPartsIndividually || meshData?.partTransformsBaked === true) {
return null;
}
return part?.transform || null;
}
function normalizeStepModulePoint(value, fallback = [0, 0, 0]) {
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
return [
toNumber(value[0], fallback[0] || 0),
toNumber(value[1], fallback[1] || 0),
toNumber(value[2], fallback[2] || 0)
];
}
if (value && typeof value === "object") {
return [
toNumber(value.x, fallback[0] || 0),
toNumber(value.y, fallback[1] || 0),
toNumber(value.z, fallback[2] || 0)
];
}
return [...fallback];
}
function stepModuleVector3(THREE, value, fallback = [0, 0, 0]) {
const point = normalizeStepModulePoint(value, fallback);
return new THREE.Vector3(point[0], point[1], point[2]);
}
function buildStepModuleRotateMatrix(THREE, spec = {}) {
const axis = stepModuleVector3(THREE, spec.axis, [0, 0, 1]);
if (axis.lengthSq() <= 1e-12) {
axis.set(0, 0, 1);
}
axis.normalize();
const origin = stepModuleVector3(THREE, spec.origin, [0, 0, 0]);
const angleRad = Number.isFinite(Number(spec.angleRad))
? Number(spec.angleRad)
: (toNumber(spec.angleDeg, 0) * Math.PI) / 180;
return new THREE.Matrix4()
.makeTranslation(origin.x, origin.y, origin.z)
.multiply(new THREE.Matrix4().makeRotationAxis(axis, angleRad))
.multiply(new THREE.Matrix4().makeTranslation(-origin.x, -origin.y, -origin.z));
}
function buildStepModuleScaleMatrix(THREE, spec = {}) {
const rawScale = spec.scale ?? spec;
const scale = Array.isArray(rawScale) || ArrayBuffer.isView(rawScale)
? normalizeStepModulePoint(rawScale, [1, 1, 1])
: [toNumber(rawScale, 1), toNumber(rawScale, 1), toNumber(rawScale, 1)];
const origin = stepModuleVector3(THREE, spec.origin, [0, 0, 0]);
return new THREE.Matrix4()
.makeTranslation(origin.x, origin.y, origin.z)
.multiply(new THREE.Matrix4().makeScale(scale[0], scale[1], scale[2]))
.multiply(new THREE.Matrix4().makeTranslation(-origin.x, -origin.y, -origin.z));
}
function buildStepModuleTranslateMatrix(THREE, value) {
const translate = normalizeStepModulePoint(value, [0, 0, 0]);
return new THREE.Matrix4().makeTranslation(translate[0], translate[1], translate[2]);
}
function buildStepModuleSingleEffectMatrix(THREE, spec = {}) {
if (!spec || typeof spec !== "object") {
return new THREE.Matrix4();
}
if (Array.isArray(spec.matrix) && spec.matrix.length === 16) {
return buildPartTransformMatrix(THREE, spec.matrix);
}
const matrix = new THREE.Matrix4();
if (spec.scale !== undefined) {
matrix.premultiply(buildStepModuleScaleMatrix(THREE, spec));
}
if (spec.rotate && typeof spec.rotate === "object") {
matrix.premultiply(buildStepModuleRotateMatrix(THREE, spec.rotate));
}
if (spec.translate !== undefined) {
matrix.premultiply(buildStepModuleTranslateMatrix(THREE, spec.translate));
}
return matrix;
}
export function buildStepModuleEffectMatrix(THREE, spec = {}) {
if (!spec || typeof spec !== "object") {
return new THREE.Matrix4();
}
const steps = Array.isArray(spec.transforms) ? spec.transforms : [spec];
const matrix = new THREE.Matrix4();
for (const step of steps) {
matrix.premultiply(buildStepModuleSingleEffectMatrix(THREE, step));
}
return matrix;
}
function matrixHasTransform(matrix, epsilon = 1e-6) {
if (!matrix?.elements || matrix.elements.length !== 16) {
return false;
}
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
return matrix.elements.some((value, index) => Math.abs(Number(value) - identity[index]) > epsilon);
}
export function resetStepModuleRecordEffects(records) {
for (const record of Array.isArray(records) ? records : []) {
record.effectMatrix = null;
record.effectStyle = null;
record.effectVisible = null;
record.effectHighlighted = false;
}
}
function allStepModulePartIds(meshData, displayRecords) {
const partIds = Array.isArray(meshData?.parts)
? meshData.parts.map((part) => String(part?.id || part?.occurrenceId || "").trim()).filter(Boolean)
: [];
if (partIds.length) {
return uniqueStrings(partIds);
}
const displayPartIds = (Array.isArray(displayRecords) ? displayRecords : [])
.map((record) => String(record?.partId || "").trim())
.filter(Boolean);
return uniqueStrings(displayPartIds.length ? displayPartIds : ["__model__"]);
}
function resolveStepModuleEffectTargetPartIds(target, features, meshData, displayRecords) {
if (target && typeof target === "object" && !Array.isArray(target)) {
if (Array.isArray(target.partIds)) {
return uniqueStrings(target.partIds);
}
if (target.partId) {
return uniqueStrings([target.partId]);
}
if (target.feature) {
return resolveStepModuleEffectTargetPartIds(target.feature, features, meshData, displayRecords);
}
if (target.ref) {
return stepModuleTargetPartIds(String(target.ref), features, meshData);
}
}
const normalizedTarget = String(target || "").trim();
if (normalizedTarget === "*" || normalizedTarget === "all" || normalizedTarget === "__all__") {
return allStepModulePartIds(meshData, displayRecords);
}
return stepModuleTargetPartIds(target, features, meshData);
}
export function createStepModuleEffectsApi(THREE, {
meshData,
features,
runtime,
effectsByPartId,
onTransformEffect
}) {
const ensureEffect = (partId) => {
const id = String(partId || "").trim();
if (!id) {
return null;
}
const current = effectsByPartId.get(id) || {
matrix: null,
style: null,
visible: null,
highlighted: false
};
effectsByPartId.set(id, current);
return current;
};
const forEachTarget = (target, callback) => {
const partIds = resolveStepModuleEffectTargetPartIds(target, features, meshData, runtime?.displayRecords);
for (const partId of partIds) {
const effect = ensureEffect(partId);
if (effect) {
callback(effect, partId);
}
}
return partIds;
};
return {
transform(target, spec) {
const matrix = buildStepModuleEffectMatrix(THREE, spec);
const partIds = forEachTarget(target, (effect) => {
effect.matrix = effect.matrix
? effect.matrix.clone().premultiply(matrix)
: matrix.clone();
});
if (partIds.length && matrixHasTransform(matrix)) {
onTransformEffect?.({
target,
spec,
partIds,
matrix: matrix.clone()
});
}
},
style(target, style = {}) {
if (!style || typeof style !== "object") {
return;
}
forEachTarget(target, (effect) => {
effect.style = {
...(effect.style || {}),
...style
};
});
},
visible(target, visible = true) {
forEachTarget(target, (effect) => {
effect.visible = visible !== false;
});
},
highlight(target, highlighted = true) {
forEachTarget(target, (effect) => {
effect.highlighted = highlighted !== false;
});
},
clear(target) {
const partIds = resolveStepModuleEffectTargetPartIds(target, features, meshData, runtime?.displayRecords);
for (const partId of partIds) {
effectsByPartId.delete(partId);
}
}
};
}
export function buildStepModuleTimeState(animationState = {}) {
const duration = Math.max(Number(animationState.duration) || 0, 0);
const elapsedSec = Math.max(Number(animationState.elapsedSec) || 0, 0);
return {
elapsed: elapsedSec,
elapsedSec,
duration,
progress: duration > 0 ? clamp(elapsedSec / duration, 0, 1) : 0,
cycle: duration > 0 ? elapsedSec / duration : 0,
playing: animationState.playing === true,
speed: Math.max(Number(animationState.speed) || 1, 0)
};
}
export function buildStepModuleContext({
runtime,
stepModuleRuntime,
features,
effects,
cleanup
}) {
const definition = stepModuleRuntime?.definition || null;
return {
THREE: runtime?.THREE,
scene: runtime?.scene,
modelGroup: runtime?.modelGroup,
edgesGroup: runtime?.edgesGroup,
runtime,
manifest: definition?.manifest || {},
module: definition?.module || null,
cadPath: stepModuleRuntime?.cadPath || definition?.cadPath || "",
params: stepModuleRuntime?.parameterValues || {},
features,
effects,
time: buildStepModuleTimeState(stepModuleRuntime?.animationState),
cleanup: typeof cleanup === "function" ? cleanup : () => {},
requestRender: () => runtime?.requestRender?.()
};
}
export function applyStepModuleEffectsToRecords(THREE, records, effectsByPartId) {
resetStepModuleRecordEffects(records);
for (const record of Array.isArray(records) ? records : []) {
const effect = effectsByPartId.get(String(record?.partId || "").trim());
if (!effect) {
continue;
}
record.effectMatrix = effect.matrix instanceof THREE.Matrix4 ? effect.matrix.clone() : null;
record.effectStyle = effect.style && typeof effect.style === "object" ? { ...effect.style } : null;
record.effectVisible = effect.visible;
record.effectHighlighted = effect.highlighted === true;
}
}
@@ -0,0 +1,173 @@
import {
normalizeParameterValue,
normalizeStepModuleParameterValues
} from "./stepModule.js";
export const DEFAULT_STEP_PARAMETER_FPS = 18;
export const DEFAULT_STEP_PARAMETER_DURATION_SECONDS = 4;
export const MAX_STEP_PARAMETER_FRAMES = 720;
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function positiveInteger(value, fallback) {
const parsed = Math.round(Number(value));
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function rawParamsUseEnvelope(rawParams) {
return isObject(rawParams) && (
isObject(rawParams.values)
|| isObject(rawParams.animate)
|| Object.hasOwn(rawParams, "durationSeconds")
|| Object.hasOwn(rawParams, "duration")
|| Object.hasOwn(rawParams, "fps")
|| Object.hasOwn(rawParams, "loop")
);
}
export function hasStepParameterRenderValues(value) {
return value !== undefined && value !== null;
}
export function stepParameterRenderValuesAreAnimated(rawParams) {
return isObject(rawParams) && isObject(rawParams.animate) && Object.keys(rawParams.animate).length > 0;
}
function parameterMapForDefinition(definition) {
return definition?.parameterMap && typeof definition.parameterMap === "object"
? definition.parameterMap
: {};
}
function assertKnownParameterIds(definition, values, label) {
const parameterMap = parameterMapForDefinition(definition);
for (const key of Object.keys(isObject(values) ? values : {})) {
if (!parameterMap[key]) {
throw new Error(`Unknown STEP parameter in ${label}: ${key}`);
}
}
}
function normalizeRange(definition, parameterId, rawRange) {
const parameter = parameterMapForDefinition(definition)[parameterId];
if (!parameter) {
throw new Error(`Unknown STEP parameter animation: ${parameterId}`);
}
if (parameter.type !== "number") {
throw new Error(`STEP parameter animation must be numeric: ${parameterId}`);
}
if (!isObject(rawRange)) {
throw new Error(`STEP parameter animation range must be an object: ${parameterId}`);
}
const from = Number(rawRange.from);
const to = Number(rawRange.to);
if (!Number.isFinite(from) || !Number.isFinite(to)) {
throw new Error(`STEP parameter animation range requires finite from/to values: ${parameterId}`);
}
return {
parameterId,
from,
to
};
}
function normalizeAnimationRanges(definition, rawAnimate) {
return Object.fromEntries(
Object.entries(isObject(rawAnimate) ? rawAnimate : {})
.map(([parameterId, rawRange]) => {
const normalizedId = String(parameterId || "").trim();
if (!normalizedId) {
return null;
}
const range = normalizeRange(definition, normalizedId, rawRange);
return [normalizedId, range];
})
.filter(Boolean)
);
}
export function normalizeStepParameterRenderValues(definition, rawParams = {}) {
if (!isObject(rawParams)) {
throw new Error("STEP parameters must be a JSON object");
}
const usesEnvelope = rawParamsUseEnvelope(rawParams);
const rawValues = usesEnvelope ? (rawParams.values || {}) : rawParams;
if (!isObject(rawValues)) {
throw new Error("STEP parameters.values must be a JSON object");
}
assertKnownParameterIds(definition, rawValues, "stepParameters");
const values = normalizeStepModuleParameterValues(definition, rawValues);
const animate = usesEnvelope ? normalizeAnimationRanges(definition, rawParams.animate) : {};
const animated = Object.keys(animate).length > 0;
const fps = clamp(positiveInteger(rawParams.fps, DEFAULT_STEP_PARAMETER_FPS), 1, 60);
const durationSeconds = clamp(
toFiniteNumber(rawParams.durationSeconds ?? rawParams.duration, DEFAULT_STEP_PARAMETER_DURATION_SECONDS),
0.1,
60
);
const frameCount = animated
? Math.max(2, Math.min(Math.round(fps * durationSeconds), MAX_STEP_PARAMETER_FRAMES))
: 1;
return {
values,
animate,
animated,
fps,
durationSeconds,
frameCount,
loop: rawParams.loop !== false
};
}
export function stepParameterRenderFrameProgress(params, frameIndex) {
const frameCount = Math.max(Math.floor(Number(params?.frameCount) || 1), 1);
const index = clamp(Math.floor(Number(frameIndex) || 0), 0, frameCount - 1);
if (frameCount <= 1) {
return 0;
}
return params?.loop === false
? index / (frameCount - 1)
: index / frameCount;
}
export function stepParameterRenderFrameValues(definition, params, frameIndex) {
const progress = stepParameterRenderFrameProgress(params, frameIndex);
const values = { ...(params?.values || {}) };
for (const [parameterId, range] of Object.entries(isObject(params?.animate) ? params.animate : {})) {
const parameter = parameterMapForDefinition(definition)[parameterId];
if (!parameter) {
continue;
}
values[parameterId] = normalizeParameterValue(
parameter,
range.from + ((range.to - range.from) * progress)
);
}
return values;
}
export function stepParameterRenderFrameState(params, frameIndex) {
const progress = stepParameterRenderFrameProgress(params, frameIndex);
const duration = Math.max(Number(params?.durationSeconds) || 0, 0);
const elapsedSec = progress * duration;
return {
activeId: "params",
playing: Boolean(params?.animated),
elapsedSec,
duration,
speed: 1,
loop: params?.loop !== false
};
}
@@ -0,0 +1,43 @@
import path from "node:path";
export function isPerStepViewerDirectoryName(name) {
const normalized = String(name || "").toLowerCase();
return normalized.startsWith(".") && (normalized.endsWith(".step") || normalized.endsWith(".stp"));
}
export function isInlineStepGlbArtifactPath(filePath) {
const name = path.basename(String(filePath || "")).toLowerCase();
return name.startsWith(".") && (name.endsWith(".step.glb") || name.endsWith(".stp.glb"));
}
export function isInlineStepParameterPath(filePath) {
const name = path.basename(String(filePath || "")).toLowerCase();
return name.startsWith(".") && name.endsWith(".step.js");
}
export function isPathInsidePerStepViewerDirectory(filePath) {
return String(filePath || "")
.split(path.sep)
.some((part) => isPerStepViewerDirectoryName(part));
}
export function inlineStepGlbArtifactPathForSource(sourcePath) {
return path.join(path.dirname(sourcePath), `.${path.basename(sourcePath)}.glb`);
}
export function legacyStepGlbArtifactPathForSource(sourcePath) {
return path.join(path.dirname(sourcePath), `.${path.basename(sourcePath)}`, "model.glb");
}
export function stepGlbArtifactPathForSource(sourcePath, { existsSync = null } = {}) {
const inlinePath = inlineStepGlbArtifactPathForSource(sourcePath);
if (!existsSync || existsSync(inlinePath)) {
return inlinePath;
}
return legacyStepGlbArtifactPathForSource(sourcePath);
}
export function stepParameterPathForStepSource(sourcePath) {
const stem = path.basename(sourcePath, path.extname(sourcePath));
return path.join(path.dirname(sourcePath), `.${stem}.step.js`);
}
@@ -0,0 +1,103 @@
export const STEP_TOPOLOGY_EXTENSION = "STEP_topology";
export const STEP_TOPOLOGY_SCHEMA_VERSION = 2;
export const STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM = "oc-brep-continuity-v1";
export const STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM = "oc-polygon-on-triangulation-v1";
export const STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG = 2;
export const STEP_TOPOLOGY_EDGE_SAMPLE_COUNT = 3;
export const STEP_EDGE_BARYCENTRIC_ATTRIBUTE = "_CAD_EDGE_BARYCENTRIC";
export const STEP_EDGE_CLASS_ATTRIBUTE = "_CAD_EDGE_CLASS";
export const STEP_EDGE_FLAGS = Object.freeze({
DEGENERATE: 1 << 1,
SEAM: 1 << 2,
NOT_REFERENCEABLE: 1 << 3,
BOUNDARY: 1 << 4,
NON_MANIFOLD: 1 << 5,
HARD: 1 << 6,
TANGENT: 1 << 7,
UNKNOWN_CONTINUITY: 1 << 8
});
export const STEP_EDGE_VISIBILITY_CLASSES = Object.freeze({
FEATURE: "feature",
TANGENT: "tangent",
SEAM: "seam",
DEGENERATE: "degenerate",
BOUNDARY: "boundary",
NON_MANIFOLD: "nonManifold",
UNKNOWN: "unknown"
});
export const STEP_EDGE_SURFACE_CLASS_CODES = Object.freeze({
none: 0,
feature: 1,
tangent: 2,
seam: 3,
degenerate: 4,
boundary: 5,
nonManifold: 6,
unknown: 7
});
export const STEP_EDGE_RENDER_VISIBILITY_CLASSES = Object.freeze([
STEP_EDGE_VISIBILITY_CLASSES.FEATURE,
STEP_EDGE_VISIBILITY_CLASSES.TANGENT,
STEP_EDGE_VISIBILITY_CLASSES.SEAM,
STEP_EDGE_VISIBILITY_CLASSES.DEGENERATE
]);
export function isCurrentStepTopologySchemaVersion(value) {
return Number(value) === STEP_TOPOLOGY_SCHEMA_VERSION;
}
export function stepEdgeSurfaceClassCode(edge = {}) {
const flags = Number(edge?.flags || 0);
const visibilityClass = String(edge?.visibilityClass || "").trim();
if (flags & STEP_EDGE_FLAGS.DEGENERATE || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.DEGENERATE) {
return STEP_EDGE_SURFACE_CLASS_CODES.degenerate;
}
if (flags & STEP_EDGE_FLAGS.SEAM || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.SEAM) {
return STEP_EDGE_SURFACE_CLASS_CODES.seam;
}
if (flags & STEP_EDGE_FLAGS.BOUNDARY || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.BOUNDARY) {
return STEP_EDGE_SURFACE_CLASS_CODES.boundary;
}
if (flags & STEP_EDGE_FLAGS.NON_MANIFOLD || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.NON_MANIFOLD) {
return STEP_EDGE_SURFACE_CLASS_CODES.nonManifold;
}
if (flags & STEP_EDGE_FLAGS.UNKNOWN_CONTINUITY || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.UNKNOWN) {
return STEP_EDGE_SURFACE_CLASS_CODES.unknown;
}
if (flags & STEP_EDGE_FLAGS.TANGENT || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.TANGENT) {
return STEP_EDGE_SURFACE_CLASS_CODES.tangent;
}
if (visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.FEATURE || flags & STEP_EDGE_FLAGS.HARD) {
return STEP_EDGE_SURFACE_CLASS_CODES.feature;
}
return STEP_EDGE_SURFACE_CLASS_CODES.feature;
}
export function isDisplayableStepEdgeSurfaceClassCode(value) {
const code = Number(value);
return Number.isFinite(code) &&
code !== STEP_EDGE_SURFACE_CLASS_CODES.none &&
code !== STEP_EDGE_SURFACE_CLASS_CODES.degenerate;
}
export function stepTopologyCapabilities() {
return {
edgeClassification: {
algorithm: STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM,
angularToleranceDeg: STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG,
samples: STEP_TOPOLOGY_EDGE_SAMPLE_COUNT
},
surfaceEdgeRendering: {
algorithm: STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM,
primitiveAttributes: {
barycentric: STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
class: STEP_EDGE_CLASS_ATTRIBUTE
},
classCodes: STEP_EDGE_SURFACE_CLASS_CODES,
visibilityClasses: STEP_EDGE_RENDER_VISIBILITY_CLASSES
}
};
}
@@ -0,0 +1,254 @@
import {
buildTransformedDisplayEdgeRuntime,
buildTransformedSelectorRuntime
} from "../lib/selectors/runtime.js";
import {
shouldUseTopologyDisplayEdges
} from "./topologyDisplayEdges.js";
const displayEdgeVisibilityClassRuntimeCache = new WeakMap();
const transformedSelectorRuntimeCache = new WeakMap();
const transformedDisplayEdgeRuntimeCache = new WeakMap();
const MAX_TRANSFORMED_RUNTIME_CACHE_ENTRIES = 32;
export function topologyDisplayEdgeSurfaceOffsetForSettings(edgeSettings = {}) {
return 0;
}
export function applyTopologyDisplayEdgeSurfaceOffset(records, edgeSettings = {}) {
// Topology display edges must depth-test against the true solid surface.
// Pushing the whole mesh away makes close-but-hidden edges bleed through at distance.
for (const record of Array.isArray(records) ? records : []) {
const material = record?.material;
if (!material) {
continue;
}
material.polygonOffset = false;
material.polygonOffsetFactor = 0;
material.polygonOffsetUnits = 0;
material.needsUpdate = true;
}
}
export function disposeTopologyDisplayEdgeObject(object) {
if (!object) {
return;
}
while (object.children?.length) {
disposeTopologyDisplayEdgeObject(object.children[0]);
}
object.parent?.remove?.(object);
if (typeof object.userData?.beforeDispose === "function") {
object.userData.beforeDispose(object);
delete object.userData.beforeDispose;
}
if (object.userData?.disposeGeometry !== false) {
object.geometry?.dispose?.();
}
if (object.userData?.disposeMaterial !== false) {
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
material?.dispose?.();
}
}
}
export function matrixHasTransform(matrix, epsilon = 1e-6) {
if (!matrix?.elements || matrix.elements.length !== 16) {
return false;
}
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
return matrix.elements.some((value, index) => Math.abs(Number(value) - identity[index]) > epsilon);
}
export function rowMajorArrayFromMatrix4(matrix) {
if (!matrix?.elements || matrix.elements.length !== 16) {
return null;
}
const elements = matrix.elements;
return [
elements[0], elements[4], elements[8], elements[12],
elements[1], elements[5], elements[9], elements[13],
elements[2], elements[6], elements[10], elements[14],
elements[3], elements[7], elements[11], elements[15],
];
}
function transformSignatureValue(value) {
const number = Number(value);
return Number.isFinite(number) ? String(number) : "0";
}
function transformEntriesSignature(transforms) {
if (!transforms?.size) {
return "";
}
return [...transforms.entries()]
.map(([partId, transform]) => {
const transformValues = Array.isArray(transform) || ArrayBuffer.isView(transform)
? Array.from(transform, transformSignatureValue)
: [];
return `${String(partId || "").trim()}:${transformValues.join(",")}`;
})
.sort()
.join("|");
}
function cachedTransformedRuntime(cache, baseRuntime, transforms, buildRuntime) {
if (!baseRuntime || !transforms?.size) {
return null;
}
const signature = transformEntriesSignature(transforms);
if (!signature) {
return null;
}
let runtimeBySignature = cache.get(baseRuntime);
if (!runtimeBySignature) {
runtimeBySignature = new Map();
cache.set(baseRuntime, runtimeBySignature);
}
if (runtimeBySignature.has(signature)) {
return runtimeBySignature.get(signature);
}
const runtime = buildRuntime(baseRuntime, transforms);
runtimeBySignature.set(signature, runtime);
if (runtimeBySignature.size > MAX_TRANSFORMED_RUNTIME_CACHE_ENTRIES) {
runtimeBySignature.delete(runtimeBySignature.keys().next().value);
}
return runtime;
}
export function selectorTransformsFromDisplayRecords(displayRecords) {
const transforms = new Map();
for (const record of Array.isArray(displayRecords) ? displayRecords : []) {
const partId = String(record?.partId || "").trim();
if (!partId || !record?.effectMatrix || !matrixHasTransform(record.effectMatrix)) {
continue;
}
const transform = rowMajorArrayFromMatrix4(record.effectMatrix);
if (transform) {
transforms.set(partId, transform);
}
}
return transforms;
}
function runtimeHasVisibilityClassRows(runtime) {
return Array.isArray(runtime?.edges) &&
runtime.edges.some((row) => String(row?.visibilityClass || "").trim());
}
function displayEdgeRowsCanUseSelectorClasses(displayEdgeRuntime, selectorRuntime) {
const displayRows = Array.isArray(displayEdgeRuntime?.edges) ? displayEdgeRuntime.edges : [];
const selectorRows = Array.isArray(selectorRuntime?.edges) ? selectorRuntime.edges : [];
const alignedRows = displayRows.every((row, rowIndex) => {
const selectorRow = selectorRows[rowIndex] || {};
const displayStart = Number(row?.segmentStart);
const selectorStart = Number(selectorRow?.segmentStart);
const displayCount = Number(row?.segmentCount);
const selectorCount = Number(selectorRow?.segmentCount);
return (
(!Number.isFinite(displayStart) || !Number.isFinite(selectorStart) || displayStart === selectorStart) &&
(!Number.isFinite(displayCount) || !Number.isFinite(selectorCount) || displayCount === selectorCount)
);
});
return Boolean(
displayRows.length &&
displayRows.length === selectorRows.length &&
alignedRows &&
!runtimeHasVisibilityClassRows(displayEdgeRuntime) &&
runtimeHasVisibilityClassRows(selectorRuntime)
);
}
function runtimeUsesSurfaceOwnedEdges(runtime) {
return Boolean(runtime?.surfaceEdgeRendering || Number(runtime?.schemaVersion || 0) >= 3);
}
export function displayEdgeRuntimeWithSelectorVisibilityClasses(displayEdgeRuntime, selectorRuntime) {
if (!displayEdgeRowsCanUseSelectorClasses(displayEdgeRuntime, selectorRuntime)) {
return displayEdgeRuntime || null;
}
const cached = displayEdgeVisibilityClassRuntimeCache.get(displayEdgeRuntime);
if (cached?.selectorRuntime === selectorRuntime) {
return cached.runtime;
}
const runtime = {
...displayEdgeRuntime,
edges: displayEdgeRuntime.edges.map((row, rowIndex) => ({
...row,
visibilityClass: selectorRuntime.edges[rowIndex]?.visibilityClass || "feature"
}))
};
displayEdgeVisibilityClassRuntimeCache.set(displayEdgeRuntime, { selectorRuntime, runtime });
return runtime;
}
export function resolveTopologyDisplayEdgeRuntimes({
selectorRuntime = null,
displayEdgeRuntime = null,
displayRecords = null,
transformDisplayEdges = true
} = {}) {
const selectorTransforms = selectorTransformsFromDisplayRecords(displayRecords);
const baseDisplayEdgeRuntime = displayEdgeRuntimeWithSelectorVisibilityClasses(displayEdgeRuntime, selectorRuntime);
const transformedSelectorRuntime = selectorTransforms.size && selectorRuntime
? cachedTransformedRuntime(
transformedSelectorRuntimeCache,
selectorRuntime,
selectorTransforms,
buildTransformedSelectorRuntime
)
: null;
const transformedDisplayEdgeRuntime = transformDisplayEdges && selectorTransforms.size && baseDisplayEdgeRuntime
? cachedTransformedRuntime(
transformedDisplayEdgeRuntimeCache,
baseDisplayEdgeRuntime,
selectorTransforms,
buildTransformedDisplayEdgeRuntime
)
: null;
const activeSelectorRuntime = transformedSelectorRuntime || selectorRuntime || null;
const activeDisplayEdgeRuntime = transformedDisplayEdgeRuntime || baseDisplayEdgeRuntime || null;
return {
selectorTransforms,
transformCount: selectorTransforms.size,
transformedSelectorRuntime,
transformedDisplayEdgeRuntime,
selectorRuntime: activeSelectorRuntime,
displayEdgeRuntime: activeDisplayEdgeRuntime,
topologyRuntime: activeDisplayEdgeRuntime || activeSelectorRuntime
};
}
export function shouldRenderTopologyDisplayEdges({
edgesVisible = false,
wireframeMode = false,
cadEdgeSource = false,
displayEdgeRuntime = null,
selectorRuntime = null,
edgeSettings = null
} = {}) {
return Boolean(
edgesVisible &&
!wireframeMode &&
cadEdgeSource &&
!runtimeUsesSurfaceOwnedEdges(displayEdgeRuntime || selectorRuntime) &&
shouldUseTopologyDisplayEdges(displayEdgeRuntime || selectorRuntime, edgeSettings)
);
}
export function shouldUseRecordTopologyEdgeTransforms({
transformDetected = false,
topologyDisplayEdgesVisible = false,
displayEdgeRuntime = null,
displayRecords = []
} = {}) {
return Boolean(
transformDetected &&
topologyDisplayEdgesVisible &&
displayEdgeRuntime &&
Array.isArray(displayRecords) &&
displayRecords.length > 1
);
}
@@ -0,0 +1,434 @@
function isFloat32Array(value) {
return value instanceof Float32Array;
}
function isUint32Array(value) {
return value instanceof Uint32Array;
}
function pointDistance(left, right) {
return Math.hypot(
Number(right?.[0] || 0) - Number(left?.[0] || 0),
Number(right?.[1] || 0) - Number(left?.[1] || 0),
Number(right?.[2] || 0) - Number(left?.[2] || 0)
);
}
function boundsForPolylines(polylines) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const positions of polylines) {
for (let offset = 0; offset + 2 < positions.length; offset += 3) {
min[0] = Math.min(min[0], positions[offset]);
min[1] = Math.min(min[1], positions[offset + 1]);
min[2] = Math.min(min[2], positions[offset + 2]);
max[0] = Math.max(max[0], positions[offset]);
max[1] = Math.max(max[1], positions[offset + 1]);
max[2] = Math.max(max[2], positions[offset + 2]);
}
}
if (!Number.isFinite(min[0]) || !Number.isFinite(max[0])) {
return null;
}
return { min, max };
}
function stitchToleranceForPolylines(polylines) {
const bounds = boundsForPolylines(polylines);
if (!bounds) {
return 1e-5;
}
return Math.max(1e-5, pointDistance(bounds.min, bounds.max) * 1e-7);
}
function pointFromProxy(proxy, vertexIndex) {
const offset = Number(vertexIndex) * 3;
if (
!Number.isInteger(vertexIndex) ||
offset < 0 ||
offset + 2 >= proxy.edgePositions.length
) {
return null;
}
return [
proxy.edgePositions[offset],
proxy.edgePositions[offset + 1],
proxy.edgePositions[offset + 2]
];
}
function normalizePartIdSet(value) {
return new Set(
(Array.isArray(value) ? value : [value])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
}
function normalizeVisibilityClassSet(value) {
const values = (Array.isArray(value) ? value : [value])
.map((item) => String(item || "").trim().toLowerCase())
.filter(Boolean);
return values.length ? new Set(values) : null;
}
function occurrenceMatchesPartIds(occurrenceId, partIds) {
const normalizedOccurrenceId = String(occurrenceId || "").trim();
if (!normalizedOccurrenceId || !partIds?.size) {
return false;
}
for (const partId of partIds) {
if (normalizedOccurrenceId === partId || normalizedOccurrenceId.startsWith(`${partId}.`)) {
return true;
}
}
return false;
}
function edgeRowMatchesPartIds(row, partIds) {
return occurrenceMatchesPartIds(row?.occurrenceId || row?.partId || row?.id, partIds);
}
function edgeRowPassesPartFilter(row, includePartIds, excludePartIds) {
if (includePartIds.size && !edgeRowMatchesPartIds(row, includePartIds)) {
return false;
}
if (excludePartIds.size && edgeRowMatchesPartIds(row, excludePartIds)) {
return false;
}
return true;
}
function edgeRowPassesVisibilityClassFilter(row, visibilityClasses) {
if (!visibilityClasses?.size) {
return true;
}
const visibilityClass = String(row?.visibilityClass || "feature").trim().toLowerCase() || "feature";
return visibilityClasses.has(visibilityClass);
}
function edgeRowPassesFilters(row, includePartIds, excludePartIds, visibilityClasses) {
return (
edgeRowPassesPartFilter(row, includePartIds, excludePartIds) &&
edgeRowPassesVisibilityClassFilter(row, visibilityClasses)
);
}
function pushPoint(target, point, tolerance = 1e-7) {
if (!point) {
return;
}
const last = target.length >= 3
? [target[target.length - 3], target[target.length - 2], target[target.length - 1]]
: null;
if (!last || pointDistance(last, point) > tolerance) {
target.push(point[0], point[1], point[2]);
}
}
function buildTopologyPolylinesForRun(proxy, segmentStart, segmentCount) {
const polylines = [];
let current = [];
let lastPoint = null;
for (let segmentIndex = segmentStart; segmentIndex < segmentStart + segmentCount; segmentIndex += 1) {
const startVertexIndex = Number(proxy.edgeIndices[segmentIndex * 2]);
const endVertexIndex = Number(proxy.edgeIndices[(segmentIndex * 2) + 1]);
const startPoint = pointFromProxy(proxy, startVertexIndex);
const endPoint = pointFromProxy(proxy, endVertexIndex);
if (!startPoint || !endPoint || pointDistance(startPoint, endPoint) <= 1e-10) {
continue;
}
if (lastPoint && pointDistance(lastPoint, startPoint) > 1e-5) {
if (current.length >= 6) {
polylines.push(new Float32Array(current));
}
current = [];
lastPoint = null;
}
if (!lastPoint) {
pushPoint(current, startPoint);
}
pushPoint(current, endPoint);
lastPoint = endPoint;
}
if (current.length >= 6) {
polylines.push(new Float32Array(current));
}
return polylines;
}
function buildTopologyPolylinesForEdgeId(proxy, edgeIds, rowIndex, segmentCount) {
const polylines = [];
let runStart = -1;
let runCount = 0;
const flushRun = () => {
if (runStart >= 0 && runCount > 0) {
polylines.push(...buildTopologyPolylinesForRun(proxy, runStart, runCount));
}
runStart = -1;
runCount = 0;
};
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
if (Number(edgeIds[segmentIndex]) === rowIndex) {
if (runStart < 0) {
runStart = segmentIndex;
}
runCount += 1;
} else {
flushRun();
}
}
flushRun();
return polylines;
}
function polylinePoint(positions, vertexIndex) {
const offset = vertexIndex * 3;
return [
positions[offset],
positions[offset + 1],
positions[offset + 2]
];
}
function polylineStart(positions) {
return polylinePoint(positions, 0);
}
function polylineEnd(positions) {
return polylinePoint(positions, Math.floor(positions.length / 3) - 1);
}
function reversePolyline(positions) {
const reversed = [];
for (let offset = positions.length - 3; offset >= 0; offset -= 3) {
reversed.push(positions[offset], positions[offset + 1], positions[offset + 2]);
}
return reversed;
}
function appendPolyline(target, source, { skipFirst = true, tolerance = 1e-7 } = {}) {
const startOffset = skipFirst ? 3 : 0;
for (let offset = startOffset; offset + 2 < source.length; offset += 3) {
pushPoint(target, [source[offset], source[offset + 1], source[offset + 2]], tolerance);
}
}
function closePolylineIfNeeded(positions, tolerance) {
if (positions.length < 12) {
return positions;
}
const start = polylineStart(positions);
const end = polylineEnd(positions);
if (pointDistance(start, end) > tolerance) {
return positions;
}
const output = Array.from(positions);
output[output.length - 3] = output[0];
output[output.length - 2] = output[1];
output[output.length - 1] = output[2];
return new Float32Array(output);
}
export function stitchTopologyDisplayEdgePolylines(polylines) {
if (!Array.isArray(polylines) || polylines.length <= 1) {
return polylines || [];
}
const tolerance = stitchToleranceForPolylines(polylines);
const chains = polylines
.filter((positions) => positions?.length >= 6)
.map((positions) => Array.from(positions));
for (let index = 0; index < chains.length; index += 1) {
let changed = true;
while (changed) {
changed = false;
for (let candidateIndex = index + 1; candidateIndex < chains.length; candidateIndex += 1) {
const current = chains[index];
const candidate = chains[candidateIndex];
const currentStart = polylineStart(current);
const currentEnd = polylineEnd(current);
const candidateStart = polylineStart(candidate);
const candidateEnd = polylineEnd(candidate);
let merged = null;
if (pointDistance(currentEnd, candidateStart) <= tolerance) {
merged = [...current];
appendPolyline(merged, candidate, { tolerance });
} else if (pointDistance(currentEnd, candidateEnd) <= tolerance) {
merged = [...current];
appendPolyline(merged, reversePolyline(candidate), { tolerance });
} else if (pointDistance(currentStart, candidateEnd) <= tolerance) {
merged = [...candidate];
appendPolyline(merged, current, { tolerance });
} else if (pointDistance(currentStart, candidateStart) <= tolerance) {
merged = reversePolyline(candidate);
appendPolyline(merged, current, { tolerance });
}
if (merged) {
chains[index] = merged;
chains.splice(candidateIndex, 1);
changed = true;
break;
}
}
}
}
return chains
.filter((positions) => positions.length >= 6)
.map((positions) => closePolylineIfNeeded(new Float32Array(positions), tolerance));
}
export function hasTopologyDisplayEdgeProxy(selectorRuntime) {
const proxy = selectorRuntime?.proxy || {};
return (
isFloat32Array(proxy.edgePositions) &&
isUint32Array(proxy.edgeIndices) &&
proxy.edgePositions.length >= 6 &&
proxy.edgeIndices.length >= 2
);
}
export function hasTopologyDisplayEdgeClassification(selectorRuntime) {
return hasTopologyDisplayEdgeProxy(selectorRuntime);
}
export function shouldUseTopologyDisplayEdges(selectorRuntime) {
return !selectorRuntime?.surfaceEdgeRendering &&
Number(selectorRuntime?.schemaVersion || 0) < 3 &&
hasTopologyDisplayEdgeProxy(selectorRuntime);
}
export function buildTopologyDisplayEdgePositions(selectorRuntime, {
includePartIds = [],
excludePartIds = [],
visibilityClasses = null
} = {}) {
if (!hasTopologyDisplayEdgeProxy(selectorRuntime)) {
return null;
}
const proxy = selectorRuntime.proxy;
const segmentCount = Math.floor(proxy.edgeIndices.length / 2);
if (segmentCount <= 0) {
return null;
}
const includePartIdSet = normalizePartIdSet(includePartIds);
const excludePartIdSet = normalizePartIdSet(excludePartIds);
const hasPartFilter = includePartIdSet.size > 0 || excludePartIdSet.size > 0;
const visibilityClassSet = normalizeVisibilityClassSet(visibilityClasses);
const edgeRows = Array.isArray(selectorRuntime.edges) ? selectorRuntime.edges : [];
const hasVisibilityClassFilter = Boolean(visibilityClassSet?.size && edgeRows.length);
const hasEdgeRowFilter = hasPartFilter || hasVisibilityClassFilter;
const edgeIds = isUint32Array(proxy.edgeIds) ? proxy.edgeIds : null;
const linePositions = new Float32Array(segmentCount * 6);
let writeOffset = 0;
const writeSegment = (segmentIndex) => {
const startVertexIndex = Number(proxy.edgeIndices[segmentIndex * 2]);
const endVertexIndex = Number(proxy.edgeIndices[(segmentIndex * 2) + 1]);
const startPoint = pointFromProxy(proxy, startVertexIndex);
const endPoint = pointFromProxy(proxy, endVertexIndex);
if (!startPoint || !endPoint || pointDistance(startPoint, endPoint) <= 1e-10) {
return;
}
linePositions[writeOffset] = startPoint[0];
linePositions[writeOffset + 1] = startPoint[1];
linePositions[writeOffset + 2] = startPoint[2];
linePositions[writeOffset + 3] = endPoint[0];
linePositions[writeOffset + 4] = endPoint[1];
linePositions[writeOffset + 5] = endPoint[2];
writeOffset += 6;
};
if (hasEdgeRowFilter) {
if (!edgeRows.length) {
return null;
}
for (let rowIndex = 0; rowIndex < edgeRows.length; rowIndex += 1) {
const row = edgeRows[rowIndex];
if (!edgeRowPassesFilters(row, includePartIdSet, excludePartIdSet, visibilityClassSet)) {
continue;
}
const start = Number(row?.segmentStart || 0);
const count = Number(row?.segmentCount || 0);
if (Number.isInteger(start) && Number.isInteger(count) && start >= 0 && count > 0 && start + count <= segmentCount) {
for (let segmentIndex = start; segmentIndex < start + count; segmentIndex += 1) {
writeSegment(segmentIndex);
}
continue;
}
if (edgeIds) {
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
if (Number(edgeIds[segmentIndex]) === rowIndex) {
writeSegment(segmentIndex);
}
}
}
}
} else {
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
writeSegment(segmentIndex);
}
}
if (writeOffset <= 0) {
return null;
}
return writeOffset === linePositions.length ? linePositions : linePositions.subarray(0, writeOffset);
}
export function buildTopologyDisplayEdgePolylines(selectorRuntime, {
includePartIds = [],
excludePartIds = [],
visibilityClasses = null
} = {}) {
if (!hasTopologyDisplayEdgeProxy(selectorRuntime)) {
return [];
}
const proxy = selectorRuntime.proxy;
const segmentCount = Math.floor(proxy.edgeIndices.length / 2);
if (segmentCount <= 0) {
return [];
}
const edgeRows = Array.isArray(selectorRuntime.edges) ? selectorRuntime.edges : [];
const edgeIds = isUint32Array(proxy.edgeIds) ? proxy.edgeIds : null;
const includePartIdSet = normalizePartIdSet(includePartIds);
const excludePartIdSet = normalizePartIdSet(excludePartIds);
const hasPartFilter = includePartIdSet.size > 0 || excludePartIdSet.size > 0;
const visibilityClassSet = normalizeVisibilityClassSet(visibilityClasses);
const hasVisibilityClassFilter = Boolean(visibilityClassSet?.size && edgeRows.length);
const hasEdgeRowFilter = hasPartFilter || hasVisibilityClassFilter;
const polylines = [];
if (edgeRows.length) {
for (let rowIndex = 0; rowIndex < edgeRows.length; rowIndex += 1) {
const row = edgeRows[rowIndex];
if (!row) {
continue;
}
if (!edgeRowPassesFilters(row, includePartIdSet, excludePartIdSet, visibilityClassSet)) {
continue;
}
const start = Number(row.segmentStart || 0);
const count = Number(row.segmentCount || 0);
if (Number.isInteger(start) && Number.isInteger(count) && start >= 0 && count > 0 && start + count <= segmentCount) {
polylines.push(...buildTopologyPolylinesForRun(proxy, start, count));
} else if (edgeIds) {
polylines.push(...buildTopologyPolylinesForEdgeId(proxy, edgeIds, rowIndex, segmentCount));
}
}
return stitchTopologyDisplayEdgePolylines(polylines);
}
if (hasEdgeRowFilter) {
return [];
}
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
const rowIndex = edgeIds ? Number(edgeIds[segmentIndex]) : -1;
void rowIndex;
polylines.push(...buildTopologyPolylinesForRun(proxy, segmentIndex, 1));
}
return stitchTopologyDisplayEdgePolylines(polylines);
}
@@ -0,0 +1,39 @@
export function cadWebGlRendererAttributes({
alpha = true,
antialias = true,
powerPreference = "high-performance",
preserveDrawingBuffer = false,
logarithmicDepthBuffer = true
} = {}) {
return {
alpha,
antialias,
powerPreference,
preserveDrawingBuffer,
logarithmicDepthBuffer
};
}
export function fallbackCadWebGlRendererAttributes(options = {}) {
return {
...cadWebGlRendererAttributes(options),
antialias: false,
powerPreference: "default",
logarithmicDepthBuffer: false
};
}
export function createCadWebGlRenderer(THREE, {
allowFallback = false,
isRecoverableError = () => true,
...options
} = {}) {
try {
return new THREE.WebGLRenderer(cadWebGlRendererAttributes(options));
} catch (error) {
if (!allowFallback || !isRecoverableError(error)) {
throw error;
}
return new THREE.WebGLRenderer(fallbackCadWebGlRendererAttributes(options));
}
}
@@ -0,0 +1,4 @@
export * from "./common/cadScene.js";
export * from "./common/renderModel.js";
export * from "./common/source.js";
export * from "./common/themeSettings.js";
@@ -0,0 +1,531 @@
import { mergeBounds } from "../urdf/kinematics.js";
const IDENTITY_TRANSFORM = Object.freeze([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
function toTransformArray(value) {
if (!Array.isArray(value) || value.length !== 16) {
return [...IDENTITY_TRANSFORM];
}
return value.map((component, index) => Number.isFinite(Number(component)) ? Number(component) : IDENTITY_TRANSFORM[index]);
}
export function assemblyMeshDescriptor(topologyManifest) {
const mesh = topologyManifest?.assembly?.mesh;
return mesh && typeof mesh === "object" ? mesh : null;
}
export function assemblyUsesSelfContainedMesh(topologyManifest) {
return String(assemblyMeshDescriptor(topologyManifest)?.addressing || "").trim() === "gltf-node-extras";
}
export function assemblyRootFromTopology(topologyManifest) {
const root = topologyManifest?.assembly?.root;
return root && typeof root === "object" ? root : null;
}
function toVectorArray(value) {
if (!Array.isArray(value) || value.length < 3) {
return null;
}
const vector = value.slice(0, 3).map((component) => Number(component));
return vector.every((component) => Number.isFinite(component)) ? vector : null;
}
function normalizeMateEndpoint(endpoint) {
if (!endpoint || typeof endpoint !== "object") {
return null;
}
const result = {
part: String(endpoint.part || "").trim(),
frame: String(endpoint.frame || "").trim()
};
const position = toVectorArray(endpoint.position);
const orientation = toVectorArray(endpoint.orientation);
if (position) {
result.position = position;
}
if (orientation) {
result.orientation = orientation;
}
const axes = endpoint.axes && typeof endpoint.axes === "object" ? endpoint.axes : null;
if (axes) {
const normalizedAxes = {};
for (const key of ["x", "y", "z"]) {
const axis = toVectorArray(axes[key]);
if (axis) {
normalizedAxes[key] = axis;
}
}
if (Object.keys(normalizedAxes).length) {
result.axes = normalizedAxes;
}
}
return result.position || result.orientation || result.part || result.frame ? result : null;
}
export function assemblyMatesFromTopology(topologyManifest) {
const mates = topologyManifest?.assemblyMates;
if (!Array.isArray(mates)) {
return [];
}
return mates
.filter((mate) => mate && typeof mate === "object")
.map((mate, index) => {
const id = String(mate.id || `m${index + 1}`).trim() || `m${index + 1}`;
return {
id,
label: String(mate.label || id).trim() || id,
sourceLabel: String(mate.sourceLabel || mate.name || "").trim(),
type: String(mate.type || mate.relation || "mate").trim(),
relation: String(mate.relation || mate.type || "mate").trim(),
fixed: String(mate.fixed || "").trim(),
moving: String(mate.moving || "").trim(),
parameters: mate.parameters && typeof mate.parameters === "object" ? mate.parameters : {},
fixedEndpoint: normalizeMateEndpoint(mate.fixedEndpoint),
movingEndpoint: normalizeMateEndpoint(mate.movingEndpoint)
};
});
}
export function flattenAssemblyLeafParts(root) {
const leafParts = [];
const stack = root ? [root] : [];
while (stack.length) {
const node = stack.pop();
const children = Array.isArray(node?.children) ? node.children : [];
if (children.length) {
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
continue;
}
if (String(node?.nodeType || "").trim() === "part") {
leafParts.push(node);
}
}
return leafParts;
}
export function flattenAssemblyNodes(root) {
const nodes = [];
const stack = root ? [root] : [];
while (stack.length) {
const node = stack.pop();
nodes.push(node);
const children = Array.isArray(node?.children) ? node.children : [];
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
}
return nodes;
}
export function findAssemblyNode(root, nodeId) {
const normalizedNodeId = String(nodeId || "").trim();
if (!root || !normalizedNodeId || normalizedNodeId === "root") {
return root || null;
}
const stack = [root];
while (stack.length) {
const node = stack.pop();
if (String(node?.id || "").trim() === normalizedNodeId) {
return node;
}
const children = Array.isArray(node?.children) ? node.children : [];
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
}
return null;
}
export function rootAssemblyInspectionNodeId(root) {
return String(root?.id || "").trim() || "root";
}
export function normalizeAssemblyInspectionNodeId(root, nodeId) {
if (!root) {
return "";
}
const rootId = rootAssemblyInspectionNodeId(root);
const normalizedNodeId = String(nodeId || "").trim();
if (!normalizedNodeId || normalizedNodeId === "root" || normalizedNodeId === rootId) {
return rootId;
}
const node = findAssemblyNode(root, normalizedNodeId);
return String(node?.id || "").trim() || rootId;
}
export function assemblyInspectionNode(root, nodeId) {
if (!root) {
return null;
}
return findAssemblyNode(root, normalizeAssemblyInspectionNodeId(root, nodeId)) || root;
}
function directChildAssemblyNodeIds(node) {
return (Array.isArray(node?.children) ? node.children : [])
.map((child) => String(child?.id || "").trim())
.filter(Boolean);
}
export function selectableAssemblyNodeIdsForInspection(root, nodeId) {
const inspectedNode = assemblyInspectionNode(root, nodeId);
return directChildAssemblyNodeIds(inspectedNode);
}
export function treeSelectableAssemblyNodeIdsForInspection(root, nodeId) {
const inspectedNode = assemblyInspectionNode(root, nodeId);
return directChildAssemblyNodeIds(inspectedNode);
}
export function focusedLeafPartIdsForAssemblyInspection(root, nodeId) {
const inspectedNodeId = normalizeAssemblyInspectionNodeId(root, nodeId);
const rootId = rootAssemblyInspectionNodeId(root);
if (!root || !inspectedNodeId || inspectedNodeId === rootId) {
return [];
}
return descendantLeafPartIds(assemblyInspectionNode(root, inspectedNodeId));
}
export function descendantLeafPartIds(node) {
return flattenAssemblyLeafParts(node)
.map((part) => String(part?.id || "").trim())
.filter(Boolean);
}
export function representativeAssemblyLeafPartId(node) {
const nodeId = String(node?.id || "").trim();
if (!node) {
return "";
}
if (String(node?.nodeType || "").trim() === "part") {
return nodeId;
}
const declaredLeafPartIds = Array.isArray(node?.leafPartIds)
? node.leafPartIds.map((id) => String(id || "").trim()).filter(Boolean)
: [];
if (declaredLeafPartIds.length) {
return declaredLeafPartIds[0];
}
return descendantLeafPartIds(node)[0] || nodeId;
}
export function buildAssemblyLeafToNodePickMap(nodes) {
const map = new Map();
for (const node of Array.isArray(nodes) ? nodes : []) {
const nodeId = String(node?.id || "").trim();
if (!nodeId) {
continue;
}
const leafPartIds = Array.isArray(node?.leafPartIds) && node.leafPartIds.length
? node.leafPartIds
: descendantLeafPartIds(node);
for (const leafPartId of leafPartIds) {
const normalizedLeafPartId = String(leafPartId || "").trim();
if (normalizedLeafPartId) {
map.set(normalizedLeafPartId, nodeId);
}
}
}
return map;
}
export function resolveAssemblyPickedPartId(partId, {
pickPartIdMap,
validLeafPartIds = []
} = {}) {
const normalizedPartId = String(partId || "").trim();
if (!normalizedPartId) {
return "";
}
const validLeafPartIdSet = validLeafPartIds instanceof Set
? validLeafPartIds
: new Set(
(Array.isArray(validLeafPartIds) ? validLeafPartIds : [])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const mappedPartId = pickPartIdMap instanceof Map
? String(pickPartIdMap.get(normalizedPartId) || "").trim()
: "";
if (mappedPartId) {
return mappedPartId;
}
if (validLeafPartIdSet.size && validLeafPartIdSet.has(normalizedPartId)) {
return normalizedPartId;
}
return mappedPartId || normalizedPartId;
}
export function leafPartIdsForAssemblySelection(partId, {
assemblyPartMap,
fallbackPartId = "",
validLeafPartIds = []
} = {}) {
const normalizedPartId = String(partId || "").trim();
const validLeafPartIdSet = validLeafPartIds instanceof Set
? validLeafPartIds
: new Set(
(Array.isArray(validLeafPartIds) ? validLeafPartIds : [])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const leafIdIsValid = (id) => {
return !validLeafPartIdSet.size || validLeafPartIdSet.has(id);
};
const normalizeLeafIds = (leafPartIds) => {
const seen = new Set();
const result = [];
for (const leafPartId of Array.isArray(leafPartIds) ? leafPartIds : []) {
const normalizedLeafPartId = String(leafPartId || "").trim();
if (!normalizedLeafPartId || seen.has(normalizedLeafPartId) || !leafIdIsValid(normalizedLeafPartId)) {
continue;
}
seen.add(normalizedLeafPartId);
result.push(normalizedLeafPartId);
}
return result;
};
if (normalizedPartId) {
const selectedNode = assemblyPartMap instanceof Map
? assemblyPartMap.get(normalizedPartId) || null
: null;
const selectedLeafPartIds = selectedNode
? normalizeLeafIds(descendantLeafPartIds(selectedNode))
: normalizeLeafIds([normalizedPartId]);
if (selectedLeafPartIds.length) {
return selectedLeafPartIds;
}
}
const normalizedFallbackPartId = String(fallbackPartId || "").trim();
return normalizeLeafIds([normalizedFallbackPartId]);
}
export function assemblyBreadcrumb(root, nodeId) {
const normalizedNodeId = String(nodeId || "").trim();
if (!root) {
return [];
}
const path = [];
function visit(node) {
path.push(node);
if (!normalizedNodeId || normalizedNodeId === "root" || String(node?.id || "").trim() === normalizedNodeId) {
return true;
}
for (const child of Array.isArray(node?.children) ? node.children : []) {
if (visit(child)) {
return true;
}
}
path.pop();
return false;
}
return visit(root) ? [...path] : [root];
}
function meshPartId(part) {
return String(part?.occurrenceId || part?.id || "").trim();
}
function meshPartNumericValue(part, key) {
return Math.max(0, Math.floor(Number(part?.[key]) || 0));
}
function meshPartIdMatches(part, ids) {
const partIds = [
String(part?.occurrenceId || "").trim(),
String(part?.id || "").trim()
].filter(Boolean);
return partIds.some((partId) => ids.has(partId));
}
function meshPartIdHasPrefix(part, prefixes) {
const partIds = [
String(part?.occurrenceId || "").trim(),
String(part?.id || "").trim()
].filter(Boolean);
return partIds.some((partId) => prefixes.some((prefix) => partId.startsWith(prefix)));
}
function meshPartsForTopologyLeaf(meshData, manifestPart) {
const allParts = Array.isArray(meshData?.parts) ? meshData.parts : [];
const ids = new Set(
[manifestPart?.occurrenceId, manifestPart?.id]
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const exactParts = allParts.filter((part) => meshPartIdMatches(part, ids));
if (exactParts.length) {
return exactParts;
}
const prefixes = [...ids].map((id) => `${id}.`);
return prefixes.length ? allParts.filter((part) => meshPartIdHasPrefix(part, prefixes)) : [];
}
export function buildSelfContainedAssemblyMeshData(topologyManifest, meshData) {
const assemblyRoot = assemblyRootFromTopology(topologyManifest);
if (!assemblyRoot) {
throw new Error("Assembly topology is missing assembly.root");
}
const manifestParts = flattenAssemblyLeafParts(assemblyRoot);
const partSources = [];
const meshlessLeafPartIds = [];
for (const manifestPart of manifestParts) {
const partId = String(manifestPart?.id || manifestPart?.occurrenceId || "").trim();
const occurrenceId = String(manifestPart?.occurrenceId || manifestPart?.id || "").trim();
const sourceParts = meshPartsForTopologyLeaf(meshData, manifestPart);
if (!sourceParts.length) {
const meshlessLeafPartId = occurrenceId || partId;
if (meshlessLeafPartId) {
meshlessLeafPartIds.push(meshlessLeafPartId);
}
continue;
}
partSources.push({
manifestPart,
sourceParts
});
}
if (!partSources.length) {
throw new Error("Assembly topology did not match any renderable GLB nodes");
}
let totalVertexCount = 0;
let totalIndexCount = 0;
for (const { sourceParts } of partSources) {
for (const sourcePart of sourceParts) {
totalVertexCount += meshPartNumericValue(sourcePart, "vertexCount");
totalIndexCount += meshPartNumericValue(sourcePart, "triangleCount") * 3;
}
}
const sourceVertices = meshData?.vertices || new Float32Array(0);
const sourceNormals = meshData?.normals || new Float32Array(0);
const sourceColors = meshData?.colors || new Float32Array(0);
const sourceSurfaceEdgeBarycentric = meshData?.surfaceEdgeBarycentric || new Float32Array(0);
const sourceSurfaceEdgeClass = meshData?.surfaceEdgeClass || new Uint8Array(0);
const sourceIndices = meshData?.indices || new Uint32Array(0);
const hasSourceColors = sourceColors.length === sourceVertices.length && sourceColors.length > 0;
const hasSurfaceEdgeAttributes =
sourceSurfaceEdgeBarycentric.length === sourceVertices.length &&
sourceSurfaceEdgeClass.length === sourceVertices.length &&
sourceVertices.length > 0;
const vertices = new Float32Array(totalVertexCount * 3);
const normals = new Float32Array(totalVertexCount * 3);
const colors = hasSourceColors ? new Float32Array(totalVertexCount * 3) : new Float32Array(0);
const surfaceEdgeBarycentric = hasSurfaceEdgeAttributes ? new Float32Array(totalVertexCount * 3) : new Float32Array(0);
const surfaceEdgeClass = hasSurfaceEdgeAttributes ? new Uint8Array(totalVertexCount * 3) : new Uint8Array(0);
const indices = new Uint32Array(totalIndexCount);
const parts = [];
let vertexOffset = 0;
let indexOffset = 0;
for (const { manifestPart, sourceParts } of partSources) {
const partId = String(manifestPart?.id || manifestPart?.occurrenceId || "").trim();
const occurrenceId = String(manifestPart?.occurrenceId || manifestPart?.id || "").trim();
const firstMeshPart = sourceParts[0];
const partVertexOffset = vertexOffset;
const partTriangleOffset = Math.floor(indexOffset / 3);
const sourcePartRanges = [];
for (const sourcePart of sourceParts) {
const sourceVertexOffset = meshPartNumericValue(sourcePart, "vertexOffset");
const sourceVertexCount = meshPartNumericValue(sourcePart, "vertexCount");
const sourceTriangleOffset = meshPartNumericValue(sourcePart, "triangleOffset");
const sourceTriangleCount = meshPartNumericValue(sourcePart, "triangleCount");
const rangeTriangleOffset = Math.floor(indexOffset / 3) - partTriangleOffset;
sourcePartRanges.push({
occurrenceId: meshPartId(sourcePart),
primitiveIndex: meshPartNumericValue(sourcePart, "primitiveIndex"),
triangleOffset: rangeTriangleOffset,
triangleCount: sourceTriangleCount
});
const sourcePositionStart = sourceVertexOffset * 3;
const sourcePositionEnd = sourcePositionStart + sourceVertexCount * 3;
vertices.set(sourceVertices.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
if (sourceNormals.length >= sourcePositionEnd) {
normals.set(sourceNormals.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
if (hasSourceColors && sourceColors.length >= sourcePositionEnd) {
colors.set(sourceColors.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
if (hasSurfaceEdgeAttributes && sourceSurfaceEdgeBarycentric.length >= sourcePositionEnd) {
surfaceEdgeBarycentric.set(sourceSurfaceEdgeBarycentric.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
if (hasSurfaceEdgeAttributes && sourceSurfaceEdgeClass.length >= sourcePositionEnd) {
surfaceEdgeClass.set(sourceSurfaceEdgeClass.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
const sourceIndexStart = sourceTriangleOffset * 3;
const sourceIndexEnd = sourceIndexStart + sourceTriangleCount * 3;
for (let index = sourceIndexStart; index < sourceIndexEnd; index += 1) {
indices[indexOffset] = sourceIndices[index] - sourceVertexOffset + vertexOffset;
indexOffset += 1;
}
vertexOffset += sourceVertexCount;
}
const sourcePath = String(manifestPart?.sourcePath || "").trim();
const displayName = String(
manifestPart?.displayName ||
manifestPart?.instancePath ||
manifestPart?.occurrenceId ||
sourcePath ||
firstMeshPart?.label ||
firstMeshPart?.name ||
meshPartId(firstMeshPart)
).trim();
const sourceBounds = mergeBounds(sourceParts.map((part) => part.bounds));
parts.push({
...firstMeshPart,
...manifestPart,
id: partId || occurrenceId || meshPartId(firstMeshPart),
occurrenceId: occurrenceId || partId || meshPartId(firstMeshPart),
name: displayName,
label: displayName,
nodeType: "part",
sourceKind: String(manifestPart?.sourceKind || "").trim(),
sourcePath,
partSourcePath: sourcePath,
sourceBounds,
bounds: manifestPart?.bbox || sourceBounds,
transform: toTransformArray(manifestPart?.worldTransform || manifestPart?.transform),
hasSourceColors: manifestPartUsesSourceColors(manifestPart) && (
hasSourceColors || sourceParts.some((part) => !!part.hasSourceColors || !!part.color)
),
vertexOffset: partVertexOffset,
vertexCount: vertexOffset - partVertexOffset,
triangleOffset: partTriangleOffset,
triangleCount: Math.floor(indexOffset / 3) - partTriangleOffset,
sourcePartRanges,
edgeIndexOffset: 0,
edgeIndexCount: 0
});
}
return {
...meshData,
vertices,
indices,
normals,
colors,
surfaceEdgeBarycentric,
surfaceEdgeClass,
edge_indices: new Uint32Array(0),
parts,
bounds: mergeBounds(parts.map((part) => part.bounds)) || meshData?.bounds,
assemblyRoot,
assemblyMates: assemblyMatesFromTopology(topologyManifest),
meshlessLeafPartIds,
partTransformsBaked: true,
has_source_colors: hasSourceColors
};
}
function manifestPartUsesSourceColors(part) {
return part?.useSourceColors !== false;
}
@@ -0,0 +1,20 @@
export async function mapWithConcurrency(items, concurrency, mapper) {
const list = Array.isArray(items) ? items : [];
const workerCount = Math.min(
list.length,
Math.max(1, Math.floor(Number(concurrency) || 1))
);
const results = new Array(list.length);
let nextIndex = 0;
async function runWorker() {
while (nextIndex < list.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(list[currentIndex], currentIndex);
}
}
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
return results;
}
@@ -0,0 +1,174 @@
const CAD_TOKEN_RE = /^\s*#([^\s]*)/;
const OCCURRENCE_SELECTOR_RE = /^o((?:\d+)(?:\.\d+)*)$/;
const OCCURRENCE_ENTITY_SELECTOR_RE = /^o((?:\d+)(?:\.\d+)*)\.([sfev])(\d+)$/;
const ENTITY_SELECTOR_RE = /^([sfev])(\d+)$/;
function selectorTypeForKind(kind) {
if (kind === "s") {
return "shape";
}
if (kind === "f") {
return "face";
}
if (kind === "e") {
return "edge";
}
return "vertex";
}
export function normalizeCadPath(rawCadPath) {
let normalized = String(rawCadPath || "").replace(/\\/g, "/").trim().replace(/^\/+|\/+$/g, "");
if (!normalized) {
return "";
}
if (normalized.split("/").some((part) => !part || part === "." || part === "..")) {
return "";
}
return normalized;
}
function parseStructuredSelector(rawSelector, { inheritedOccurrenceId = "" } = {}) {
const selector = String(rawSelector || "").trim().replace(/^#/, "");
if (!selector) {
return null;
}
const occurrenceEntityMatch = selector.match(OCCURRENCE_ENTITY_SELECTOR_RE);
if (occurrenceEntityMatch) {
const occurrenceId = `o${occurrenceEntityMatch[1]}`;
const kind = occurrenceEntityMatch[2];
const ordinal = Number(occurrenceEntityMatch[3]);
return {
selectorType: selectorTypeForKind(kind),
occurrenceId,
ordinal,
kind,
canonical: `${occurrenceId}.${kind}${ordinal}`
};
}
const occurrenceMatch = selector.match(OCCURRENCE_SELECTOR_RE);
if (occurrenceMatch) {
return {
selectorType: "occurrence",
occurrenceId: `o${occurrenceMatch[1]}`,
ordinal: null,
kind: "",
canonical: `o${occurrenceMatch[1]}`
};
}
const entityMatch = selector.match(ENTITY_SELECTOR_RE);
if (entityMatch) {
const kind = entityMatch[1];
const ordinal = Number(entityMatch[2]);
if (inheritedOccurrenceId) {
return {
selectorType: selectorTypeForKind(kind),
occurrenceId: inheritedOccurrenceId,
ordinal,
kind,
canonical: `${inheritedOccurrenceId}.${kind}${ordinal}`
};
}
return {
selectorType: selectorTypeForKind(kind),
occurrenceId: "",
ordinal,
kind,
canonical: `${kind}${ordinal}`
};
}
return {
selectorType: "opaque",
occurrenceId: "",
ordinal: null,
kind: "",
canonical: selector
};
}
export function parseCadRefSelector(rawSelector, options = {}) {
return parseStructuredSelector(rawSelector, options);
}
export function normalizeCadRefSelectors(selectors) {
const rawSelectors = Array.isArray(selectors)
? selectors.flatMap((selector) => String(selector || "").split(","))
: String(selectors || "").split(",");
const normalizedSelectors = [];
let inheritedOccurrenceId = "";
for (const rawSelector of rawSelectors) {
const parsedSelector = parseStructuredSelector(rawSelector, { inheritedOccurrenceId });
if (!parsedSelector) {
continue;
}
normalizedSelectors.push(parsedSelector.canonical);
if (parsedSelector.occurrenceId) {
inheritedOccurrenceId = parsedSelector.occurrenceId;
}
}
return normalizedSelectors;
}
export function sortCadRefSelectors(selectors) {
const normalizedSelectors = normalizeCadRefSelectors(selectors);
const uniqueSelectors = [...new Set(normalizedSelectors.filter(Boolean))];
const rank = {
occurrence: 1,
shape: 2,
face: 3,
edge: 4,
vertex: 5,
opaque: 6
};
return uniqueSelectors
.map((selector, index) => ({ selector, index, parsed: parseStructuredSelector(selector) }))
.sort((left, right) => {
const leftOccurrence = left.parsed?.occurrenceId || "";
const rightOccurrence = right.parsed?.occurrenceId || "";
if (leftOccurrence !== rightOccurrence) {
return leftOccurrence.localeCompare(rightOccurrence);
}
const leftRank = rank[left.parsed?.selectorType] ?? 99;
const rightRank = rank[right.parsed?.selectorType] ?? 99;
if (leftRank !== rightRank) {
return leftRank - rightRank;
}
const leftOrdinal = Number(left.parsed?.ordinal || 0);
const rightOrdinal = Number(right.parsed?.ordinal || 0);
if (leftOrdinal !== rightOrdinal) {
return leftOrdinal - rightOrdinal;
}
if (left.selector !== right.selector) {
return left.selector.localeCompare(right.selector);
}
return left.index - right.index;
})
.map((item) => item.selector);
}
export function parseCadRefToken(copyText) {
const match = String(copyText || "").trim().match(CAD_TOKEN_RE);
if (!match) {
return null;
}
const selectorText = String(match[1] || "").trim();
return {
token: match[0],
cadPath: "",
selectors: normalizeCadRefSelectors(selectorText)
};
}
export function buildCadRefToken({ cadPath = "", selector = "", selectors } = {}) {
void cadPath;
const selectorList = selectors !== undefined ? sortCadRefSelectors(selectors) : sortCadRefSelectors(selector ? [selector] : []);
if (!selectorList.length) {
return "#";
}
return `#${selectorList.join(",")}`;
}
@@ -0,0 +1 @@
export * from "../common/displaySettings.js";
@@ -0,0 +1,504 @@
const DXF_RENDER_SCHEMA_VERSION = 1;
const SUPPORTED_ENTITY_TYPES = new Set(["LINE", "ARC", "CIRCLE", "LWPOLYLINE"]);
const ANGLE_EPSILON = 1e-9;
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function semanticKindForLayer(layerName) {
return String(layerName || "").trim().toLowerCase().includes("bend") ? "bend" : "cut";
}
function normalizeLayerName(value) {
const text = String(value || "").trim();
return text || "0";
}
function normalizeAngle(angleDeg) {
const value = angleDeg % 360;
return value < 0 ? value + 360 : value;
}
function angleInSweep(angleDeg, startAngleDeg, sweepAngleDeg) {
const absSweepAngleDeg = Math.abs(sweepAngleDeg);
if (absSweepAngleDeg >= 360 - ANGLE_EPSILON) {
return true;
}
if (sweepAngleDeg >= 0) {
const normalizedDelta = (normalizeAngle(angleDeg) - normalizeAngle(startAngleDeg) + 360) % 360;
return normalizedDelta <= absSweepAngleDeg + ANGLE_EPSILON;
}
const normalizedDelta = (normalizeAngle(startAngleDeg) - normalizeAngle(angleDeg) + 360) % 360;
return normalizedDelta <= absSweepAngleDeg + ANGLE_EPSILON;
}
function pointOnCircle(center, radius, angleDeg) {
const radians = (angleDeg * Math.PI) / 180;
return [
center[0] + radius * Math.cos(radians),
center[1] + radius * Math.sin(radians)
];
}
function arcExtremaPoints(arc) {
const endAngleDeg = arc.startAngleDeg + arc.sweepAngleDeg;
const points = [
pointOnCircle(arc.center, arc.radius, arc.startAngleDeg),
pointOnCircle(arc.center, arc.radius, endAngleDeg)
];
for (const candidateAngle of [0, 90, 180, 270]) {
if (angleInSweep(candidateAngle, arc.startAngleDeg, arc.sweepAngleDeg)) {
points.push(pointOnCircle(arc.center, arc.radius, candidateAngle));
}
}
return points;
}
function expandBounds(current, nextBounds) {
if (!current) {
return nextBounds;
}
return {
minX: Math.min(current.minX, nextBounds.minX),
minY: Math.min(current.minY, nextBounds.minY),
maxX: Math.max(current.maxX, nextBounds.maxX),
maxY: Math.max(current.maxY, nextBounds.maxY)
};
}
function lineBounds(line) {
return {
minX: Math.min(line.start[0], line.end[0]),
minY: Math.min(line.start[1], line.end[1]),
maxX: Math.max(line.start[0], line.end[0]),
maxY: Math.max(line.start[1], line.end[1])
};
}
function circleBounds(circle) {
return {
minX: circle.center[0] - circle.radius,
minY: circle.center[1] - circle.radius,
maxX: circle.center[0] + circle.radius,
maxY: circle.center[1] + circle.radius
};
}
function arcBounds(arc) {
const points = arcExtremaPoints(arc);
const xs = points.map((point) => point[0]);
const ys = points.map((point) => point[1]);
return {
minX: Math.min(...xs),
minY: Math.min(...ys),
maxX: Math.max(...xs),
maxY: Math.max(...ys)
};
}
function screenPoint(point, { minX, maxY }) {
return [point[0] - minX, maxY - point[1]];
}
function formatNumber(value) {
const rounded = Math.round(toFiniteNumber(value) * 1_000_000) / 1_000_000;
return Math.abs(rounded) < ANGLE_EPSILON ? 0 : rounded;
}
function parseRecordPairs(text) {
const normalized = String(text || "").replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
if (lines.length && lines[lines.length - 1] === "") {
lines.pop();
}
if (lines.length % 2 !== 0) {
throw new Error("DXF group code stream is malformed");
}
const pairs = [];
for (let index = 0; index < lines.length; index += 2) {
const code = Number.parseInt(lines[index].trim(), 10);
if (!Number.isFinite(code)) {
throw new Error(`Invalid DXF group code: ${JSON.stringify(lines[index])}`);
}
pairs.push({
code,
value: lines[index + 1] ?? ""
});
}
return pairs;
}
function parseHeader(records) {
let sourceUnits = 0;
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
if (record.code !== 9) {
continue;
}
const variableName = String(record.value || "").trim();
const valueRecord = records[index + 1];
if (!valueRecord) {
continue;
}
if (variableName === "$INSUNITS") {
sourceUnits = Math.max(0, Math.trunc(toFiniteNumber(valueRecord.value, 0)));
}
}
return {
sourceUnits,
defaultThicknessMm: 0
};
}
function parseLineEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const startX = toFiniteNumber(records.find((record) => record.code === 10)?.value);
const startY = toFiniteNumber(records.find((record) => record.code === 20)?.value);
const endX = toFiniteNumber(records.find((record) => record.code === 11)?.value);
const endY = toFiniteNumber(records.find((record) => record.code === 21)?.value);
return {
layer,
start: [startX, startY],
end: [endX, endY]
};
}
function parseArcEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const radius = toFiniteNumber(records.find((record) => record.code === 40)?.value, -1);
if (radius <= 0) {
throw new Error("Invalid DXF arc radius");
}
const startAngleDeg = normalizeAngle(toFiniteNumber(records.find((record) => record.code === 50)?.value));
const endAngleDeg = normalizeAngle(toFiniteNumber(records.find((record) => record.code === 51)?.value));
let sweepAngleDeg = (endAngleDeg - startAngleDeg + 360) % 360;
if (sweepAngleDeg <= ANGLE_EPSILON) {
sweepAngleDeg = 360;
}
return {
layer,
center: [
toFiniteNumber(records.find((record) => record.code === 10)?.value),
toFiniteNumber(records.find((record) => record.code === 20)?.value)
],
radius,
startAngleDeg,
sweepAngleDeg,
endAngleDeg: startAngleDeg + sweepAngleDeg
};
}
function parseCircleEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const radius = toFiniteNumber(records.find((record) => record.code === 40)?.value, -1);
if (radius <= 0) {
throw new Error("Invalid DXF circle radius");
}
return {
layer,
center: [
toFiniteNumber(records.find((record) => record.code === 10)?.value),
toFiniteNumber(records.find((record) => record.code === 20)?.value)
],
radius
};
}
function arcFromBulgeSegment(layer, start, end, bulge) {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const chordLength = Math.hypot(dx, dy);
if (chordLength <= ANGLE_EPSILON || Math.abs(bulge) <= ANGLE_EPSILON) {
return null;
}
const includedAngleRad = 4 * Math.atan(bulge);
const radius = (chordLength * (1 + bulge * bulge)) / (4 * Math.abs(bulge));
const midpoint = [
(start[0] + end[0]) / 2,
(start[1] + end[1]) / 2
];
const leftNormal = [-dy / chordLength, dx / chordLength];
const centerOffset = (chordLength * (1 - bulge * bulge)) / (4 * bulge);
const center = [
midpoint[0] + leftNormal[0] * centerOffset,
midpoint[1] + leftNormal[1] * centerOffset
];
return {
layer,
center,
radius,
startAngleDeg: normalizeAngle((Math.atan2(start[1] - center[1], start[0] - center[0]) * 180) / Math.PI),
sweepAngleDeg: (includedAngleRad * 180) / Math.PI
};
}
function parseLwpolylineEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const flags = Math.trunc(toFiniteNumber(records.find((record) => record.code === 70)?.value, 0));
const vertices = [];
let currentVertex = null;
for (const record of records) {
if (record.code === 10) {
if (currentVertex && Number.isFinite(currentVertex.point[0]) && Number.isFinite(currentVertex.point[1])) {
vertices.push(currentVertex);
}
currentVertex = { point: [toFiniteNumber(record.value), Number.NaN], bulge: 0 };
continue;
}
if (record.code === 20 && currentVertex) {
currentVertex.point[1] = toFiniteNumber(record.value);
continue;
}
if (record.code === 42 && currentVertex) {
currentVertex.bulge = toFiniteNumber(record.value);
}
}
if (currentVertex && Number.isFinite(currentVertex.point[0]) && Number.isFinite(currentVertex.point[1])) {
vertices.push(currentVertex);
}
if (vertices.length < 2) {
throw new Error("Invalid DXF LWPOLYLINE; expected at least 2 vertices");
}
const lines = [];
const arcs = [];
const addSegment = (startVertex, endVertex) => {
const start = startVertex.point;
const end = endVertex.point;
if (start[0] === end[0] && start[1] === end[1]) {
return;
}
if (Math.abs(startVertex.bulge) > ANGLE_EPSILON) {
const arc = arcFromBulgeSegment(layer, start, end, startVertex.bulge);
if (arc) {
arcs.push(arc);
}
return;
}
lines.push({ layer, start, end });
};
for (let index = 0; index < vertices.length - 1; index += 1) {
addSegment(vertices[index], vertices[index + 1]);
}
if ((flags & 1) !== 0) {
addSegment(vertices[vertices.length - 1], vertices[0]);
}
return { lines, arcs };
}
function parseEntities(records) {
const lines = [];
const arcs = [];
const circles = [];
let index = 0;
while (index < records.length) {
const startRecord = records[index];
if (startRecord.code !== 0) {
index += 1;
continue;
}
const entityType = String(startRecord.value || "").trim().toUpperCase();
if (entityType === "ENDSEC") {
break;
}
const entityRecords = [];
index += 1;
while (index < records.length && records[index].code !== 0) {
entityRecords.push(records[index]);
index += 1;
}
if (!SUPPORTED_ENTITY_TYPES.has(entityType)) {
throw new Error(`Unsupported DXF entity ${entityType}`);
}
if (entityType === "LINE") {
lines.push(parseLineEntity(entityRecords));
continue;
}
if (entityType === "ARC") {
arcs.push(parseArcEntity(entityRecords));
continue;
}
if (entityType === "CIRCLE") {
circles.push(parseCircleEntity(entityRecords));
continue;
}
const polyline = parseLwpolylineEntity(entityRecords);
lines.push(...polyline.lines);
arcs.push(...polyline.arcs);
}
if (!lines.length && !arcs.length && !circles.length) {
throw new Error("No supported DXF entities found");
}
return { lines, arcs, circles };
}
function splitSections(records) {
const sections = new Map();
let index = 0;
while (index < records.length) {
const record = records[index];
if (record.code !== 0 || String(record.value || "").trim().toUpperCase() !== "SECTION") {
index += 1;
continue;
}
const nameRecord = records[index + 1];
const sectionName = String(nameRecord?.value || "").trim().toUpperCase();
index += 2;
const sectionRecords = [];
while (index < records.length) {
const nextRecord = records[index];
if (nextRecord.code === 0 && String(nextRecord.value || "").trim().toUpperCase() === "ENDSEC") {
index += 1;
break;
}
sectionRecords.push(nextRecord);
index += 1;
}
sections.set(sectionName, sectionRecords);
}
return sections;
}
function buildPathRecord(layerName, kind, pathData) {
return {
layer: layerName,
kind,
d: pathData
};
}
function touchLayer(layerSummary, layerName) {
const existing = layerSummary.get(layerName);
if (existing) {
return existing;
}
const next = {
name: layerName,
kind: semanticKindForLayer(layerName),
pathCount: 0,
circleCount: 0
};
layerSummary.set(layerName, next);
return next;
}
export function parseDxf(dxfText, { fileRef = "", sourceUrl = "" } = {}) {
const records = parseRecordPairs(dxfText);
const sections = splitSections(records);
const header = parseHeader(sections.get("HEADER") || []);
const entities = parseEntities(sections.get("ENTITIES") || []);
let rawBounds = null;
for (const line of entities.lines) {
rawBounds = expandBounds(rawBounds, lineBounds(line));
}
for (const arc of entities.arcs) {
rawBounds = expandBounds(rawBounds, arcBounds(arc));
}
for (const circle of entities.circles) {
rawBounds = expandBounds(rawBounds, circleBounds(circle));
}
if (!rawBounds) {
throw new Error("Failed to compute DXF bounds");
}
const width = Math.max(rawBounds.maxX - rawBounds.minX, 0);
const height = Math.max(rawBounds.maxY - rawBounds.minY, 0);
const pathRecords = [];
const circleRecords = [];
const layerSummary = new Map();
for (const line of entities.lines) {
const start = screenPoint(line.start, { minX: rawBounds.minX, maxY: rawBounds.maxY });
const end = screenPoint(line.end, { minX: rawBounds.minX, maxY: rawBounds.maxY });
pathRecords.push(
buildPathRecord(
line.layer,
semanticKindForLayer(line.layer),
`M ${formatNumber(start[0])} ${formatNumber(start[1])} L ${formatNumber(end[0])} ${formatNumber(end[1])}`
)
);
touchLayer(layerSummary, line.layer).pathCount += 1;
}
for (const arc of entities.arcs) {
const start = screenPoint(pointOnCircle(arc.center, arc.radius, arc.startAngleDeg), {
minX: rawBounds.minX,
maxY: rawBounds.maxY
});
const end = screenPoint(pointOnCircle(arc.center, arc.radius, arc.startAngleDeg + arc.sweepAngleDeg), {
minX: rawBounds.minX,
maxY: rawBounds.maxY
});
const largeArcFlag = Math.abs(arc.sweepAngleDeg) > 180 + ANGLE_EPSILON ? 1 : 0;
const sweepFlag = arc.sweepAngleDeg >= 0 ? 0 : 1;
pathRecords.push(
buildPathRecord(
arc.layer,
semanticKindForLayer(arc.layer),
`M ${formatNumber(start[0])} ${formatNumber(start[1])} A ${formatNumber(arc.radius)} ${formatNumber(arc.radius)} 0 ${largeArcFlag} ${sweepFlag} ${formatNumber(end[0])} ${formatNumber(end[1])}`
)
);
touchLayer(layerSummary, arc.layer).pathCount += 1;
}
for (const circle of entities.circles) {
const center = screenPoint(circle.center, { minX: rawBounds.minX, maxY: rawBounds.maxY });
circleRecords.push({
layer: circle.layer,
kind: semanticKindForLayer(circle.layer),
cx: formatNumber(center[0]),
cy: formatNumber(center[1]),
r: formatNumber(circle.radius)
});
touchLayer(layerSummary, circle.layer).circleCount += 1;
}
return {
schemaVersion: DXF_RENDER_SCHEMA_VERSION,
fileRef,
sourceUrl,
sourceUnits: header.sourceUnits,
defaultThicknessMm: formatNumber(header.defaultThicknessMm),
bounds: {
minX: 0,
minY: 0,
maxX: formatNumber(width),
maxY: formatNumber(height),
width: formatNumber(width),
height: formatNumber(height)
},
counts: {
paths: pathRecords.length,
circles: circleRecords.length,
entities: pathRecords.length + circleRecords.length
},
layers: [...layerSummary.keys()].sort().map((name) => layerSummary.get(name)),
geometry: {
lines: entities.lines.map((line) => ({
layer: line.layer,
kind: semanticKindForLayer(line.layer),
start: [formatNumber(line.start[0]), formatNumber(line.start[1])],
end: [formatNumber(line.end[0]), formatNumber(line.end[1])]
})),
arcs: entities.arcs.map((arc) => ({
layer: arc.layer,
kind: semanticKindForLayer(arc.layer),
center: [formatNumber(arc.center[0]), formatNumber(arc.center[1])],
radius: formatNumber(arc.radius),
startAngleDeg: formatNumber(arc.startAngleDeg),
sweepAngleDeg: formatNumber(arc.sweepAngleDeg)
})),
circles: entities.circles.map((circle) => ({
layer: circle.layer,
kind: semanticKindForLayer(circle.layer),
center: [formatNumber(circle.center[0]), formatNumber(circle.center[1])],
radius: formatNumber(circle.radius)
}))
},
paths: pathRecords,
circles: circleRecords
};
}
@@ -0,0 +1,173 @@
import {
entrySourceFormat,
meshAssetKeyForEntry,
RENDER_FORMAT
} from "./fileFormats.js";
export { meshAssetKeyForEntry } from "./fileFormats.js";
function normalizeString(value) {
return String(value || "").trim();
}
function normalizedKind(entry) {
return normalizeString(entry?.kind).toLowerCase();
}
function primaryAsset(entry) {
if (!entry || typeof entry !== "object") {
return null;
}
const url = normalizeString(entry.url);
if (!url) {
return null;
}
return {
url,
hash: normalizeString(entry.hash),
bytes: Number.isFinite(Number(entry.bytes)) && Number(entry.bytes) >= 0 ? Number(entry.bytes) : 0,
};
}
function relationAsset(entry, key) {
const relation = entry?.relations?.[key];
if (!relation || typeof relation !== "object") {
return null;
}
const url = normalizeString(relation.url);
return url
? {
url,
hash: normalizeString(relation.hash),
bytes: Number.isFinite(Number(relation.bytes)) && Number(relation.bytes) >= 0 ? Number(relation.bytes) : 0,
}
: null;
}
export function entryAsset(entry, key) {
const assetKey = normalizeString(key).toLowerCase();
const kind = normalizedKind(entry);
const sourceFormat = entrySourceFormat(entry);
if (assetKey === "urdf" && kind === "srdf") {
return relationAsset(entry, "urdf");
}
if (
assetKey === kind ||
assetKey === sourceFormat ||
(sourceFormat === RENDER_FORMAT.STEP && ["glb", "topology", "selectortopology", "displayedgetopology"].includes(assetKey)) ||
(kind === "srdf" && assetKey === "srdf")
) {
return primaryAsset(entry);
}
return null;
}
export function entryAssetUrl(entry, key) {
return normalizeString(entryAsset(entry, key)?.url);
}
export function entryAssetHash(entry, key) {
return normalizeString(entryAsset(entry, key)?.hash);
}
export function entryAssetBytes(entry, key) {
const bytes = Number(entryAsset(entry, key)?.bytes);
return Number.isFinite(bytes) && bytes >= 0 ? bytes : 0;
}
export function entryMeshAssetUrl(entry) {
return entryAssetUrl(entry, meshAssetKeyForEntry(entry));
}
export function entryMeshAssetHash(entry) {
return entryAssetHash(entry, meshAssetKeyForEntry(entry));
}
export function entryMeshAssetBytes(entry) {
return entryAssetBytes(entry, meshAssetKeyForEntry(entry));
}
export function entryTopologyAssetUrl(entry) {
return entryAssetUrl(entry, "topology") || entryAssetUrl(entry, "glb");
}
export function entrySelectorTopologyAssetUrl(entry) {
return entryAssetUrl(entry, "selectorTopology") || entryTopologyAssetUrl(entry);
}
export function entryDisplayEdgeTopologyAssetUrl(entry) {
return entryAssetUrl(entry, "displayEdgeTopology") || entryTopologyAssetUrl(entry);
}
export function entryUrdfAssetHash(entry) {
return [
entryAssetHash(entry, "urdf"),
entryAssetHash(entry, "srdf"),
entryAssetHash(entry, "sdf")
].filter(Boolean).join(":");
}
export function entryMeshAssetSignature(entry) {
return String(
entry?.kind === "assembly"
? entryAssetHash(entry, "glb")
: entryMeshAssetHash(entry)
);
}
export function entryReferenceAssetSignature(entry) {
return entryAssetHash(entry, "selectorTopology") || normalizeString(entry?.hash);
}
export function entryHasMesh(entry) {
if (entrySourceFormat(entry) === RENDER_FORMAT.STEP) {
return Boolean(
entryAssetUrl(entry, "glb") &&
entryAssetHash(entry, "glb")
);
}
const meshKey = meshAssetKeyForEntry(entry);
return Boolean(entryAssetUrl(entry, meshKey) && entryAssetHash(entry, meshKey));
}
export function entryHasUrdf(entry) {
const kind = normalizeString(entry?.kind).toLowerCase();
if (kind === RENDER_FORMAT.SDF) {
return Boolean(entryAssetUrl(entry, "sdf") && entryAssetHash(entry, "sdf"));
}
return Boolean(entryAssetUrl(entry, "urdf") && entryAssetHash(entry, "urdf"));
}
export function entryHasReferences(entry) {
return Boolean(
entrySourceFormat(entry) === RENDER_FORMAT.STEP &&
entryAssetUrl(entry, "glb") &&
entryAssetHash(entry, "selectorTopology")
);
}
export function entryHasDisplayEdges(entry) {
return Boolean(
entrySourceFormat(entry) === RENDER_FORMAT.STEP &&
entryAssetUrl(entry, "glb") &&
entryAssetHash(entry, "displayEdgeTopology")
);
}
export function entryHasDxf(entry) {
return Boolean(entryAssetUrl(entry, "dxf") && entryAssetHash(entry, "dxf"));
}
export function entryHasGcode(entry) {
return Boolean(entryAssetUrl(entry, "gcode") && entryAssetHash(entry, "gcode"));
}
export function entryHasImplicitCad(entry) {
return Boolean(entryAssetUrl(entry, "implicit") && entryAssetHash(entry, "implicit"));
}
export function entryStepModuleUrl(entry) {
return entrySourceFormat(entry) === RENDER_FORMAT.STEP
? normalizeString(entry?.moduleUrl)
: "";
}
@@ -0,0 +1,207 @@
export const RENDER_FORMAT = Object.freeze({
STEP: "step",
STL: "stl",
THREE_MF: "3mf",
GLB: "glb",
GCODE: "gcode",
DXF: "dxf",
IMPLICIT: "implicit",
URDF: "urdf",
SRDF: "srdf",
SDF: "sdf"
});
export const MESH_RENDER_FORMATS = Object.freeze([
RENDER_FORMAT.STL,
RENDER_FORMAT.THREE_MF,
RENDER_FORMAT.GLB
]);
export const ROBOT_RENDER_FORMATS = Object.freeze([
RENDER_FORMAT.URDF,
RENDER_FORMAT.SRDF,
RENDER_FORMAT.SDF
]);
export function normalizeFormat(value) {
return String(value || "").trim().toLowerCase();
}
export function normalizeRenderFormat(value, { defaultFormat = RENDER_FORMAT.STEP } = {}) {
const normalized = normalizeFormat(value || defaultFormat);
if (normalized === "stp") {
return RENDER_FORMAT.STEP;
}
if (normalized === "gltf") {
return RENDER_FORMAT.GLB;
}
if (
normalized === RENDER_FORMAT.STEP ||
normalized === RENDER_FORMAT.STL ||
normalized === RENDER_FORMAT.THREE_MF ||
normalized === RENDER_FORMAT.GLB ||
normalized === RENDER_FORMAT.GCODE ||
normalized === RENDER_FORMAT.DXF ||
normalized === RENDER_FORMAT.IMPLICIT ||
normalized === RENDER_FORMAT.URDF ||
normalized === RENDER_FORMAT.SRDF ||
normalized === RENDER_FORMAT.SDF
) {
return normalized;
}
return defaultFormat;
}
export function entryKind(entry) {
return normalizeFormat(entry?.kind);
}
export function entrySourceFormat(entry) {
const kind = entryKind(entry);
if (kind === RENDER_FORMAT.DXF) {
return RENDER_FORMAT.DXF;
}
if (kind === RENDER_FORMAT.STL) {
return RENDER_FORMAT.STL;
}
if (kind === RENDER_FORMAT.THREE_MF) {
return RENDER_FORMAT.THREE_MF;
}
if (kind === RENDER_FORMAT.GLB || kind === "gltf") {
return RENDER_FORMAT.GLB;
}
if (kind === RENDER_FORMAT.GCODE) {
return RENDER_FORMAT.GCODE;
}
if (kind === RENDER_FORMAT.IMPLICIT) {
return RENDER_FORMAT.IMPLICIT;
}
if (kind === RENDER_FORMAT.URDF) {
return RENDER_FORMAT.URDF;
}
if (kind === RENDER_FORMAT.SRDF) {
return RENDER_FORMAT.SRDF;
}
if (kind === RENDER_FORMAT.SDF) {
return RENDER_FORMAT.SDF;
}
return RENDER_FORMAT.STEP;
}
export function isMeshRenderFormat(format) {
return MESH_RENDER_FORMATS.includes(normalizeFormat(format));
}
export function isRobotRenderFormat(format) {
return ROBOT_RENDER_FORMATS.includes(normalizeFormat(format));
}
export function meshAssetKeyForFormat(format) {
const normalized = normalizeFormat(format);
return isMeshRenderFormat(normalized) ? normalized : RENDER_FORMAT.GLB;
}
export function meshAssetKeyForEntry(entry) {
return meshAssetKeyForFormat(entrySourceFormat(entry));
}
export function fileSheetKindForEntry(entry) {
if (!entry) {
return "";
}
const kind = entryKind(entry);
if (kind === RENDER_FORMAT.DXF) {
return RENDER_FORMAT.DXF;
}
if (kind === RENDER_FORMAT.URDF) {
return RENDER_FORMAT.URDF;
}
if (kind === RENDER_FORMAT.SRDF) {
return RENDER_FORMAT.SRDF;
}
if (kind === RENDER_FORMAT.SDF) {
return RENDER_FORMAT.SDF;
}
if (kind === RENDER_FORMAT.GCODE) {
return RENDER_FORMAT.GCODE;
}
if (kind === RENDER_FORMAT.IMPLICIT) {
return RENDER_FORMAT.IMPLICIT;
}
if (entrySourceFormat(entry) === RENDER_FORMAT.STEP) {
return RENDER_FORMAT.STEP;
}
if (isMeshRenderFormat(entrySourceFormat(entry))) {
return "mesh";
}
return "";
}
export function fileExtensionFromPath(value, { baseUrl = "" } = {}) {
const rawValue = String(value || "").trim();
if (!rawValue) {
return "";
}
let pathname = rawValue;
try {
pathname = new URL(rawValue, baseUrl || "http://localhost/").pathname;
} catch {
pathname = rawValue.split("?")[0].split("#")[0];
}
const normalizedPath = pathname.toLowerCase();
const slashIndex = normalizedPath.lastIndexOf("/");
const dotIndex = normalizedPath.lastIndexOf(".");
return dotIndex > slashIndex ? normalizedPath.slice(dotIndex) : "";
}
export function renderFormatFromExtension(extension) {
const normalized = normalizeFormat(extension).replace(/^\./, "");
if (normalized === "step" || normalized === "stp") {
return RENDER_FORMAT.STEP;
}
if (normalized === "stl") {
return RENDER_FORMAT.STL;
}
if (normalized === "3mf") {
return RENDER_FORMAT.THREE_MF;
}
if (normalized === "glb" || normalized === "gltf") {
return RENDER_FORMAT.GLB;
}
if (normalized === "gcode") {
return RENDER_FORMAT.GCODE;
}
if (normalized === "implicit" || normalized === "implicit.js" || normalized === "implicit.mjs") {
return RENDER_FORMAT.IMPLICIT;
}
if (normalized === "dxf") {
return RENDER_FORMAT.DXF;
}
if (normalized === "urdf") {
return RENDER_FORMAT.URDF;
}
if (normalized === "srdf") {
return RENDER_FORMAT.SRDF;
}
if (normalized === "sdf") {
return RENDER_FORMAT.SDF;
}
return "";
}
export function renderFormatFromPath(value, options = {}) {
const rawValue = String(value || "").trim();
let pathname = rawValue;
try {
pathname = new URL(rawValue, options.baseUrl || "http://localhost/").pathname;
} catch {
pathname = rawValue.split("?")[0].split("#")[0];
}
const normalizedPath = pathname.toLowerCase();
if (normalizedPath.endsWith(".implicit.js") || normalizedPath.endsWith(".implicit.mjs")) {
return RENDER_FORMAT.IMPLICIT;
}
return renderFormatFromExtension(fileExtensionFromPath(value, options));
}
@@ -0,0 +1,505 @@
const DEFAULT_EXTRUSION_WIDTH_MM = 0.42;
const DEFAULT_TRAVEL_WIDTH_MM = 0.08;
const DEFAULT_EXTRUSION_HEIGHT_MM = 0.18;
const DEFAULT_TRAVEL_HEIGHT_MM = 0.04;
export const DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET = 60000;
export const GCODE_PREVIEW_DETAIL_MIN = 1;
export const GCODE_PREVIEW_DETAIL_MAX = 7;
export const DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL = 3;
const GCODE_PREVIEW_DETAIL_SEGMENT_BUDGETS = Object.freeze({
1: 25000,
2: 40000,
3: DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET,
4: 90000,
5: 140000,
6: 220000,
7: 320000
});
const MIN_GCODE_PREVIEW_SEGMENT_BUDGET = 1;
const MIN_SEGMENT_LENGTH_MM = 1e-5;
const GCODE_PREVIEW_Z_LIFT_MM = 0.035;
const DEFAULT_LAYER_COLORS = Object.freeze([
"#2563eb",
"#16a34a",
"#0891b2",
"#ca8a04",
"#7c3aed",
"#0f766e",
"#4f46e5",
"#65a30d"
]);
const SUPPORT_COLOR = "#94a3b8";
const TRAVEL_COLOR = "#64748b";
const FEATURE_CATEGORY_Z_BIAS_MM = Object.freeze({
support: 0.001,
adhesion: 0.002,
infill: 0.003,
calibration: 0.004,
other: 0.004,
surface: 0.005,
wall: 0.006
});
const TRAVEL_Z_BIAS_MM = 0.008;
const RIBBON_VERTICES_PER_SEGMENT = 4;
const RIBBON_INDICES_PER_SEGMENT = 6;
const RIBBON_NORMAL = Object.freeze([0, 0, 1]);
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeDetailMode(value) {
return String(value || "").trim().toLowerCase() === "full" ? "full" : "adaptive";
}
function normalizePreviewDetailLevel(value) {
return clamp(
Math.round(toFiniteNumber(value, DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL)),
GCODE_PREVIEW_DETAIL_MIN,
GCODE_PREVIEW_DETAIL_MAX
);
}
function segmentBudgetForPreviewDetailLevel(value) {
const detailLevel = normalizePreviewDetailLevel(value);
return GCODE_PREVIEW_DETAIL_SEGMENT_BUDGETS[detailLevel] || DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET;
}
function coordinate(value, index) {
return toFiniteNumber(value?.[index]);
}
function emptyBounds() {
return {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity]
};
}
function finalizeBounds(bounds) {
if (!bounds.min.every(Number.isFinite) || !bounds.max.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [1, 1, 1]
};
}
return {
min: bounds.min,
max: bounds.max
};
}
function segmentLength(segment) {
const start = segment?.start;
const end = segment?.end;
return Math.hypot(
coordinate(end, 0) - coordinate(start, 0),
coordinate(end, 1) - coordinate(start, 1),
coordinate(end, 2) - coordinate(start, 2)
);
}
function normalizeLayerRange(value, layerCount) {
const maxLayer = Math.max(0, Math.trunc(toFiniteNumber(layerCount, 0)) - 1);
if (!Array.isArray(value) || value.length < 2) {
return [0, maxLayer];
}
const start = clamp(Math.trunc(toFiniteNumber(value[0], 0)), 0, maxLayer);
const end = clamp(Math.trunc(toFiniteNumber(value[1], maxLayer)), 0, maxLayer);
return start <= end ? [start, end] : [end, start];
}
export function normalizeGcodePreviewOptions(options = {}, layerCount = 0) {
const source = options && typeof options === "object" ? options : {};
const detailMode = normalizeDetailMode(source.detailMode);
const detailLevel = normalizePreviewDetailLevel(source.detailLevel);
const defaultSegmentBudget = segmentBudgetForPreviewDetailLevel(detailLevel);
const explicitRange = Array.isArray(source.layerRange)
? source.layerRange
: Number.isFinite(Number(source.maxLayer))
? [0, Number(source.maxLayer)]
: null;
return {
showTravel: normalizeBoolean(source.showTravel, false),
layerRange: normalizeLayerRange(explicitRange, layerCount),
detailMode,
detailLevel,
maxVisibleSegments: detailMode === "full"
? 0
: Math.max(
Math.trunc(toFiniteNumber(source.maxVisibleSegments, defaultSegmentBudget)),
MIN_GCODE_PREVIEW_SEGMENT_BUDGET
),
extrusionWidthMm: Math.max(toFiniteNumber(source.extrusionWidthMm, DEFAULT_EXTRUSION_WIDTH_MM), 0.01),
travelWidthMm: Math.max(toFiniteNumber(source.travelWidthMm, DEFAULT_TRAVEL_WIDTH_MM), 0.01),
extrusionHeightMm: Math.max(toFiniteNumber(source.extrusionHeightMm, DEFAULT_EXTRUSION_HEIGHT_MM), 0.01),
travelHeightMm: Math.max(toFiniteNumber(source.travelHeightMm, DEFAULT_TRAVEL_HEIGHT_MM), 0.01)
};
}
function layerColor(layerIndex) {
const index = Math.max(0, Math.trunc(toFiniteNumber(layerIndex, 0)));
return DEFAULT_LAYER_COLORS[index % DEFAULT_LAYER_COLORS.length];
}
function segmentGroupKey(segment) {
if (segment.kind === "travel") {
return "travel";
}
return `layer:${Math.max(0, Math.trunc(toFiniteNumber(segment.layerIndex, 0)))}`;
}
function groupLabel(key, layerIndex) {
if (key === "travel") {
return "Travel";
}
return `Layer ${Math.max(0, Math.trunc(toFiniteNumber(layerIndex, 0))) + 1}`;
}
function colorToRgb(color) {
const normalized = String(color || "").trim();
const expanded = /^#[0-9a-fA-F]{3}$/.test(normalized)
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
: normalized;
if (!/^#[0-9a-fA-F]{6}$/.test(expanded)) {
return [1, 1, 1];
}
return [
parseInt(expanded.slice(1, 3), 16) / 255,
parseInt(expanded.slice(3, 5), 16) / 255,
parseInt(expanded.slice(5, 7), 16) / 255
];
}
function visibleSegmentMatches(segment, options) {
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
const [minLayer, maxLayer] = options.layerRange;
if (layerIndex < minLayer || layerIndex > maxLayer) {
return false;
}
if (segment?.kind === "travel") {
return options.showTravel;
}
return segment?.kind === "extrusion";
}
function analyzeVisibleSegments(gcodeData, options) {
const segments = Array.isArray(gcodeData?.segments) ? gcodeData.segments : [];
const segmentsByLayer = new Map();
let visibleSegmentCount = 0;
for (const segment of segments) {
if (!visibleSegmentMatches(segment, options) || segmentLength(segment) <= MIN_SEGMENT_LENGTH_MM) {
continue;
}
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
segmentsByLayer.set(layerIndex, (segmentsByLayer.get(layerIndex) || 0) + 1);
visibleSegmentCount += 1;
}
return {
visibleSegmentCount,
segmentsByLayer
};
}
function includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride) {
if (layerStride <= 1) {
return true;
}
return (
layerIndex === minLayer ||
layerIndex === maxLayer ||
(layerIndex - minLayer) % layerStride === 0
);
}
function countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride) {
let count = 0;
for (const [layerIndex, segmentCount] of segmentsByLayer.entries()) {
if (layerIndex >= minLayer && layerIndex <= maxLayer && includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
count += segmentCount;
}
}
return count;
}
function layerStrideForBudget(segmentsByLayer, visibleSegmentCount, options) {
const budget = Math.trunc(toFiniteNumber(options?.maxVisibleSegments, 0));
if (options?.detailMode === "full" || budget <= 0 || visibleSegmentCount <= budget) {
return 1;
}
const [minLayer, maxLayer] = options.layerRange;
const visibleLayerCount = Math.max(1, maxLayer - minLayer + 1);
for (let layerStride = 2; layerStride <= visibleLayerCount; layerStride += 1) {
if (countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride) <= budget) {
return layerStride;
}
}
return visibleLayerCount;
}
function countRenderedLayers(segmentsByLayer, minLayer, maxLayer, layerStride) {
let count = 0;
for (const layerIndex of segmentsByLayer.keys()) {
if (layerIndex >= minLayer && layerIndex <= maxLayer && includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
count += 1;
}
}
return count;
}
function updateBoundsFromCoordinates(bounds, x, y, z) {
bounds.min[0] = Math.min(bounds.min[0], x);
bounds.min[1] = Math.min(bounds.min[1], y);
bounds.min[2] = Math.min(bounds.min[2], z);
bounds.max[0] = Math.max(bounds.max[0], x);
bounds.max[1] = Math.max(bounds.max[1], y);
bounds.max[2] = Math.max(bounds.max[2], z);
}
function updateSegmentBounds(bounds, segment) {
updateBoundsFromCoordinates(
bounds,
coordinate(segment?.start, 0),
coordinate(segment?.start, 1),
coordinate(segment?.start, 2)
);
updateBoundsFromCoordinates(
bounds,
coordinate(segment?.end, 0),
coordinate(segment?.end, 1),
coordinate(segment?.end, 2)
);
}
function segmentColor(segment, key, layerIndex) {
if (key === "travel") {
return TRAVEL_COLOR;
}
if (segment?.featureCategory === "support") {
return SUPPORT_COLOR;
}
return layerColor(layerIndex);
}
function segmentZOffset(segment) {
if (segment?.kind === "travel") {
return GCODE_PREVIEW_Z_LIFT_MM + TRAVEL_Z_BIAS_MM;
}
const category = String(segment?.featureCategory || "other").trim().toLowerCase();
return GCODE_PREVIEW_Z_LIFT_MM + (FEATURE_CATEGORY_Z_BIAS_MM[category] ?? FEATURE_CATEGORY_Z_BIAS_MM.other);
}
function writeRibbonVertex({ vertices, colors, normals, bounds, offset, x, y, z, zOffset = 0, color }) {
vertices[offset] = x;
vertices[offset + 1] = y;
vertices[offset + 2] = z + zOffset;
colors[offset] = color[0];
colors[offset + 1] = color[1];
colors[offset + 2] = color[2];
normals[offset] = RIBBON_NORMAL[0];
normals[offset + 1] = RIBBON_NORMAL[1];
normals[offset + 2] = RIBBON_NORMAL[2];
updateBoundsFromCoordinates(bounds, x, y, z + zOffset);
}
function writeSegmentRibbon({ vertices, indices, colors, normals, bounds, segment, segmentIndex, width, color, zOffset }) {
const start = segment?.start;
const end = segment?.end;
const startX = coordinate(start, 0);
const startY = coordinate(start, 1);
const startZ = coordinate(start, 2);
const endX = coordinate(end, 0);
const endY = coordinate(end, 1);
const endZ = coordinate(end, 2);
const dx = endX - startX;
const dy = endY - startY;
const dz = endZ - startZ;
const length = Math.hypot(dx, dy, dz);
if (!(length > MIN_SEGMENT_LENGTH_MM)) {
return null;
}
let nx = -dy;
let ny = dx;
let normalLength = Math.hypot(nx, ny);
if (!(normalLength > MIN_SEGMENT_LENGTH_MM)) {
nx = 1;
ny = 0;
normalLength = 1;
}
const halfWidth = width / 2;
const offsetX = (nx / normalLength) * halfWidth;
const offsetY = (ny / normalLength) * halfWidth;
const vertexBase = segmentIndex * RIBBON_VERTICES_PER_SEGMENT;
const vertexOffset = vertexBase * 3;
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset,
x: startX + offsetX,
y: startY + offsetY,
z: startZ,
zOffset,
color
});
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset + 3,
x: startX - offsetX,
y: startY - offsetY,
z: startZ,
zOffset,
color
});
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset + 6,
x: endX - offsetX,
y: endY - offsetY,
z: endZ,
zOffset,
color
});
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset + 9,
x: endX + offsetX,
y: endY + offsetY,
z: endZ,
zOffset,
color
});
const indexOffset = segmentIndex * RIBBON_INDICES_PER_SEGMENT;
indices[indexOffset] = vertexBase;
indices[indexOffset + 1] = vertexBase + 1;
indices[indexOffset + 2] = vertexBase + 2;
indices[indexOffset + 3] = vertexBase;
indices[indexOffset + 4] = vertexBase + 2;
indices[indexOffset + 5] = vertexBase + 3;
return true;
}
export function buildGcodePreviewMeshData(gcodeData, options = {}) {
const layers = Array.isArray(gcodeData?.layers) ? gcodeData.layers : [];
const normalized = normalizeGcodePreviewOptions(options, layers.length);
const segments = Array.isArray(gcodeData?.segments) ? gcodeData.segments : [];
const { visibleSegmentCount, segmentsByLayer } = analyzeVisibleSegments(gcodeData, normalized);
const layerStride = layerStrideForBudget(segmentsByLayer, visibleSegmentCount, normalized);
const [minLayer, maxLayer] = normalized.layerRange;
const renderedSegmentCapacity = countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride);
const renderedLayerCount = countRenderedLayers(segmentsByLayer, minLayer, maxLayer, layerStride);
const vertices = new Float32Array(renderedSegmentCapacity * RIBBON_VERTICES_PER_SEGMENT * 3);
const indices = new Uint32Array(renderedSegmentCapacity * RIBBON_INDICES_PER_SEGMENT);
const colors = new Float32Array(vertices.length);
const normals = new Float32Array(vertices.length);
const parts = [];
const geometryBounds = emptyBounds();
const rawBounds = emptyBounds();
let currentGroup = null;
let writtenSegments = 0;
for (const segment of segments) {
if (!visibleSegmentMatches(segment, normalized) || segmentLength(segment) <= MIN_SEGMENT_LENGTH_MM) {
continue;
}
const key = segmentGroupKey(segment);
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
if (!includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
continue;
}
updateSegmentBounds(rawBounds, segment);
if (!currentGroup || currentGroup.key !== key) {
currentGroup = {
key,
layerIndex,
vertexOffset: writtenSegments * RIBBON_VERTICES_PER_SEGMENT,
triangleOffset: writtenSegments * 2,
segmentCount: 0
};
parts.push(currentGroup);
}
const added = writeSegmentRibbon({
vertices,
indices,
colors,
normals,
bounds: geometryBounds,
segment,
segmentIndex: writtenSegments,
width: segment.kind === "travel" ? normalized.travelWidthMm : normalized.extrusionWidthMm,
color: colorToRgb(segmentColor(segment, key, layerIndex)),
zOffset: segmentZOffset(segment)
});
if (added) {
currentGroup.segmentCount += 1;
writtenSegments += 1;
}
}
const finalizedParts = parts
.map((part, index) => {
const nextVertexOffset = parts[index + 1]?.vertexOffset ?? writtenSegments * RIBBON_VERTICES_PER_SEGMENT;
const nextTriangleOffset = parts[index + 1]?.triangleOffset ?? writtenSegments * 2;
return {
id: part.key === "travel" ? `gcode:travel:${index}` : `gcode:layer:${part.layerIndex}:${index}`,
label: groupLabel(part.key, part.layerIndex),
vertexOffset: part.vertexOffset,
vertexCount: Math.max(0, nextVertexOffset - part.vertexOffset),
triangleOffset: part.triangleOffset,
triangleCount: Math.max(0, nextTriangleOffset - part.triangleOffset),
bounds: finalizeBounds(rawBounds),
segmentCount: part.segmentCount
};
})
.filter((part) => part.vertexCount > 0 && part.triangleCount > 0);
const finalBounds = finalizeBounds(rawBounds);
return {
vertices,
indices,
normals,
colors,
bounds: finalBounds,
parts: finalizedParts,
has_source_colors: true,
sourceFormat: "gcode",
sourceColor: "#2563eb",
metadata: {
kind: "gcode",
previewGeometry: "ribbon",
options: normalized,
visibleSegments: visibleSegmentCount,
renderedSegments: writtenSegments,
visibleLayers: Math.max(0, maxLayer - minLayer + 1),
renderedLayers: renderedLayerCount,
layerStride,
segmentStride: 1,
decimationMode: layerStride > 1 ? "layers" : "none",
decimated: layerStride > 1,
stats: gcodeData?.stats || {},
features: Array.isArray(gcodeData?.features) ? gcodeData.features : [],
warnings: Array.isArray(gcodeData?.warnings) ? gcodeData.warnings : []
}
};
}
@@ -0,0 +1,787 @@
const GCODE_RENDER_SCHEMA_VERSION = 1;
const POSITION_EPSILON = 1e-7;
const EXTRUSION_EPSILON = 1e-7;
const ARC_CHORD_MM = 0.8;
const MIN_ARC_SEGMENTS = 6;
const MAX_ARC_SEGMENTS = 160;
const UNCLASSIFIED_FEATURE = Object.freeze({
id: "unclassified",
label: "Unclassified",
category: "other",
rawLabel: ""
});
const FEATURE_CATEGORY_ORDER = Object.freeze({
support: 0,
wall: 1,
surface: 2,
infill: 3,
adhesion: 4,
calibration: 5,
other: 6
});
const FEATURE_DEFINITIONS = Object.freeze([
{
id: "support-interface",
label: "Support interface",
category: "support",
patterns: [
/\bsupport\b.*\b(interface|roof|floor)\b/,
/\binterface\b.*\bsupport\b/
]
},
{
id: "support",
label: "Support",
category: "support",
patterns: [/\bsupport(?:s| material)?\b/]
},
{
id: "overhang-wall",
label: "Overhang wall",
category: "wall",
patterns: [/\boverhang\b.*\b(wall|perimeter)\b/]
},
{
id: "outer-wall",
label: "Outer wall",
category: "wall",
patterns: [
/\bouter\b.*\b(wall|perimeter|shell)\b/,
/\bexternal\b.*\b(perimeter|wall|shell)\b/,
/\bwall outer\b/
]
},
{
id: "inner-wall",
label: "Inner wall",
category: "wall",
patterns: [
/\binner\b.*\b(wall|perimeter|shell)\b/,
/\binternal\b.*\bperimeter\b/,
/\bwall inner\b/
]
},
{
id: "wall",
label: "Wall",
category: "wall",
patterns: [/\b(wall|perimeter|shell)\b/]
},
{
id: "bridge",
label: "Bridge",
category: "surface",
patterns: [/\bbridge\b/]
},
{
id: "top-surface",
label: "Top surface",
category: "surface",
patterns: [/\btop\b.*\b(surface|skin|shell|solid infill|infill)\b/]
},
{
id: "bottom-surface",
label: "Bottom surface",
category: "surface",
patterns: [/\bbottom\b.*\b(surface|skin|shell|solid infill|infill)\b/]
},
{
id: "surface",
label: "Surface",
category: "surface",
patterns: [/\b(skin|surface)\b/]
},
{
id: "gap-fill",
label: "Gap fill",
category: "infill",
patterns: [/\bgap\b.*\bfill\b/]
},
{
id: "internal-solid-infill",
label: "Internal solid infill",
category: "infill",
patterns: [
/\binternal\b.*\bsolid\b.*\binfill\b/,
/\bsolid\b.*\binfill\b/
]
},
{
id: "sparse-infill",
label: "Sparse infill",
category: "infill",
patterns: [
/\bsparse\b.*\binfill\b/,
/\binternal\b.*\binfill\b/,
/\bfill\b/
]
},
{
id: "infill",
label: "Infill",
category: "infill",
patterns: [/\binfill\b/]
},
{
id: "skirt",
label: "Skirt",
category: "adhesion",
patterns: [/\bskirt\b/]
},
{
id: "brim",
label: "Brim",
category: "adhesion",
patterns: [/\bbrim\b/]
},
{
id: "raft",
label: "Raft",
category: "adhesion",
patterns: [/\braft\b/]
},
{
id: "prime-tower",
label: "Prime tower",
category: "calibration",
patterns: [/\b(prime|wipe)\b.*\btower\b/]
},
{
id: "ironing",
label: "Ironing",
category: "surface",
patterns: [/\bironing\b/]
},
{
id: "flush",
label: "Flush",
category: "calibration",
patterns: [/\bflush\b/]
},
{
id: "wipe",
label: "Wipe",
category: "calibration",
patterns: [/\bwipe\b/]
},
{
id: "custom",
label: "Custom",
category: "other",
patterns: [/\bcustom\b/]
}
]);
const SUPPORTED_COMMANDS = new Set([
"G0",
"G1",
"G2",
"G3",
"G20",
"G21",
"G28",
"G90",
"G91",
"G92",
"M82",
"M83",
"M84",
"M104",
"M106",
"M107",
"M109",
"M140",
"M190"
]);
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clonePoint(point) {
return [
toFiniteNumber(point?.[0]),
toFiniteNumber(point?.[1]),
toFiniteNumber(point?.[2])
];
}
function stripComment(line) {
return String(line || "").split(";", 1)[0].trim();
}
function parseCommand(line) {
const stripped = stripComment(line).toUpperCase();
if (!stripped) {
return "";
}
const first = stripped.split(/\s+/, 1)[0];
if (/^[GMT]\d+(?:\.\d+)?$/.test(first)) {
return first.split(".", 1)[0];
}
return first;
}
function parseTokens(line) {
const stripped = stripComment(line).toUpperCase();
const tokens = {};
for (const match of stripped.matchAll(/([A-Z])([-+]?(?:\d+(?:\.\d*)?|\.\d+))/g)) {
tokens[match[1]] = Number(match[2]);
}
return tokens;
}
function commentText(line) {
const source = String(line || "");
const commentIndex = source.indexOf(";");
return commentIndex >= 0 ? source.slice(commentIndex + 1).trim() : "";
}
function normalizeFeatureText(value) {
return String(value || "")
.replace(/^["']+|["']+$/g, "")
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function featureSlug(value) {
return normalizeFeatureText(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function titleCaseFeatureLabel(value) {
const normalized = normalizeFeatureText(value);
if (!normalized) {
return "Other";
}
return normalized
.toLowerCase()
.split(" ")
.map((word) => word ? `${word[0].toUpperCase()}${word.slice(1)}` : "")
.join(" ");
}
function featureFromLabel(rawLabel) {
const normalized = normalizeFeatureText(rawLabel);
const matchText = normalized.toLowerCase();
for (const definition of FEATURE_DEFINITIONS) {
if (definition.patterns.some((pattern) => pattern.test(matchText))) {
return {
id: definition.id,
label: definition.label,
category: definition.category,
rawLabel: normalized
};
}
}
const slug = featureSlug(normalized);
return {
id: slug ? `other:${slug}` : UNCLASSIFIED_FEATURE.id,
label: slug ? titleCaseFeatureLabel(normalized) : UNCLASSIFIED_FEATURE.label,
category: "other",
rawLabel: normalized
};
}
function parseFeatureMarker(line) {
const comment = commentText(line);
if (!comment) {
return null;
}
const match = comment.match(/^(?:FEATURE|TYPE|PRINT_FEATURE|TOOLPATH(?:_TYPE)?|PATH_TYPE)\s*[:=]\s*(.+)$/i);
if (!match?.[1]) {
return null;
}
return featureFromLabel(match[1]);
}
function distance3(a, b) {
const dx = toFiniteNumber(b?.[0]) - toFiniteNumber(a?.[0]);
const dy = toFiniteNumber(b?.[1]) - toFiniteNumber(a?.[1]);
const dz = toFiniteNumber(b?.[2]) - toFiniteNumber(a?.[2]);
return Math.hypot(dx, dy, dz);
}
function roundedZ(value) {
return Math.round(toFiniteNumber(value) * 1000000) / 1000000;
}
function updateBounds(bounds, point) {
const next = clonePoint(point);
for (let axis = 0; axis < 3; axis += 1) {
bounds.min[axis] = Math.min(bounds.min[axis], next[axis]);
bounds.max[axis] = Math.max(bounds.max[axis], next[axis]);
}
}
function layerKey(z) {
return roundedZ(z).toFixed(6);
}
function layerIndexForZ(layers, z) {
if (!layers.length) {
return 0;
}
const target = roundedZ(z);
let bestIndex = 0;
let bestDistance = Infinity;
for (let index = 0; index < layers.length; index += 1) {
const distance = Math.abs(toFiniteNumber(layers[index]?.z) - target);
if (distance < bestDistance) {
bestIndex = index;
bestDistance = distance;
}
}
return bestIndex;
}
function endpointForMove(position, tokens, absolutePositioning) {
return [
Object.hasOwn(tokens, "X")
? (absolutePositioning ? tokens.X : position.x + tokens.X)
: position.x,
Object.hasOwn(tokens, "Y")
? (absolutePositioning ? tokens.Y : position.y + tokens.Y)
: position.y,
Object.hasOwn(tokens, "Z")
? (absolutePositioning ? tokens.Z : position.z + tokens.Z)
: position.z
];
}
function nextExtruder(position, tokens, absoluteExtrusion) {
if (!Object.hasOwn(tokens, "E")) {
return position.e;
}
return absoluteExtrusion ? tokens.E : position.e + tokens.E;
}
function addSegment(segments, bounds, stats, {
lineNumber,
command,
start,
end,
extrusionDelta = 0,
feedRate = null,
arc = false,
feature = null
}) {
const lengthMm = distance3(start, end);
if (lengthMm <= POSITION_EPSILON) {
if (extrusionDelta < -EXTRUSION_EPSILON) {
stats.retractMoves += 1;
} else if (extrusionDelta > EXTRUSION_EPSILON) {
stats.primeMoves += 1;
}
return;
}
const kind = extrusionDelta > EXTRUSION_EPSILON ? "extrusion" : "travel";
if (kind === "extrusion") {
stats.extrusionMoves += 1;
stats.extrusionMm += extrusionDelta;
} else {
stats.travelMoves += 1;
}
stats.movementCommands += 1;
stats.pathMm += lengthMm;
updateBounds(bounds, start);
updateBounds(bounds, end);
segments.push({
lineNumber,
command,
kind,
start: clonePoint(start),
end: clonePoint(end),
z: roundedZ(end[2]),
extrusionDelta,
feedRate: Number.isFinite(feedRate) ? feedRate : null,
lengthMm,
arc: Boolean(arc),
featureId: feature?.id || UNCLASSIFIED_FEATURE.id,
featureLabel: feature?.label || UNCLASSIFIED_FEATURE.label,
featureCategory: feature?.category || UNCLASSIFIED_FEATURE.category,
featureRaw: feature?.rawLabel || ""
});
}
function arcSegments({ start, end, tokens, clockwise }) {
if (!Object.hasOwn(tokens, "I") || !Object.hasOwn(tokens, "J")) {
return null;
}
const centerX = start[0] + tokens.I;
const centerY = start[1] + tokens.J;
const radius = Math.hypot(start[0] - centerX, start[1] - centerY);
if (!(radius > POSITION_EPSILON)) {
return null;
}
const startAngle = Math.atan2(start[1] - centerY, start[0] - centerX);
const endAngle = Math.atan2(end[1] - centerY, end[0] - centerX);
let sweep = endAngle - startAngle;
if (clockwise && sweep >= 0) {
sweep -= Math.PI * 2;
} else if (!clockwise && sweep <= 0) {
sweep += Math.PI * 2;
}
const chordSegments = Math.ceil(Math.abs(sweep * radius) / ARC_CHORD_MM);
const segmentCount = Math.max(MIN_ARC_SEGMENTS, Math.min(MAX_ARC_SEGMENTS, chordSegments));
const points = [clonePoint(start)];
for (let index = 1; index <= segmentCount; index += 1) {
const t = index / segmentCount;
const angle = startAngle + sweep * t;
points.push([
centerX + radius * Math.cos(angle),
centerY + radius * Math.sin(angle),
start[2] + (end[2] - start[2]) * t
]);
}
return points;
}
function compactBounds(bounds) {
if (!bounds.min.every(Number.isFinite) || !bounds.max.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [1, 1, 1]
};
}
return {
min: bounds.min.map((value) => roundedZ(value)),
max: bounds.max.map((value) => roundedZ(value))
};
}
function finalizeLayers(segments) {
const zValues = new Map();
for (const segment of segments) {
if (segment.kind !== "extrusion") {
continue;
}
const key = layerKey(segment.z);
if (!zValues.has(key)) {
zValues.set(key, roundedZ(segment.z));
}
}
if (!zValues.size) {
for (const segment of segments) {
const key = layerKey(segment.z);
if (!zValues.has(key)) {
zValues.set(key, roundedZ(segment.z));
}
}
}
const layers = [...zValues.values()]
.sort((left, right) => left - right)
.map((z, index) => ({
index,
z,
segmentCount: 0,
extrusionMoves: 0,
travelMoves: 0
}));
for (const segment of segments) {
const index = layerIndexForZ(layers, segment.z);
segment.layerIndex = index;
if (layers[index]) {
layers[index].segmentCount += 1;
if (segment.kind === "extrusion") {
layers[index].extrusionMoves += 1;
} else {
layers[index].travelMoves += 1;
}
}
}
return layers;
}
function ensureFeatureRecord(records, feature) {
const resolvedFeature = feature?.id ? feature : UNCLASSIFIED_FEATURE;
if (!records.has(resolvedFeature.id)) {
records.set(resolvedFeature.id, {
id: resolvedFeature.id,
label: resolvedFeature.label || UNCLASSIFIED_FEATURE.label,
category: resolvedFeature.category || UNCLASSIFIED_FEATURE.category,
rawLabels: new Set(),
markerCount: 0,
firstLine: null,
lastLine: null,
firstLayerIndex: null,
lastLayerIndex: null,
segmentCount: 0,
extrusionMoves: 0,
travelMoves: 0,
extrusionMm: 0,
pathMm: 0
});
}
const record = records.get(resolvedFeature.id);
if (resolvedFeature.rawLabel) {
record.rawLabels.add(resolvedFeature.rawLabel);
}
return record;
}
function layerRangeText(record) {
if (!Number.isInteger(record.firstLayerIndex) || !Number.isInteger(record.lastLayerIndex)) {
return "";
}
return record.firstLayerIndex === record.lastLayerIndex
? `Layer ${record.firstLayerIndex + 1}`
: `Layers ${record.firstLayerIndex + 1}-${record.lastLayerIndex + 1}`;
}
function finalizeFeatures(segments, markers) {
const records = new Map();
for (const marker of markers) {
const record = ensureFeatureRecord(records, marker.feature);
record.markerCount += 1;
record.firstLine = record.firstLine == null ? marker.lineNumber : Math.min(record.firstLine, marker.lineNumber);
record.lastLine = record.lastLine == null ? marker.lineNumber : Math.max(record.lastLine, marker.lineNumber);
}
for (const segment of segments) {
const record = ensureFeatureRecord(records, {
id: segment.featureId,
label: segment.featureLabel,
category: segment.featureCategory,
rawLabel: segment.featureRaw
});
record.segmentCount += 1;
record.pathMm += toFiniteNumber(segment.lengthMm);
if (Number.isInteger(segment.layerIndex)) {
record.firstLayerIndex = record.firstLayerIndex == null
? segment.layerIndex
: Math.min(record.firstLayerIndex, segment.layerIndex);
record.lastLayerIndex = record.lastLayerIndex == null
? segment.layerIndex
: Math.max(record.lastLayerIndex, segment.layerIndex);
}
if (segment.kind === "extrusion") {
record.extrusionMoves += 1;
record.extrusionMm += toFiniteNumber(segment.extrusionDelta);
} else if (segment.kind === "travel") {
record.travelMoves += 1;
}
}
return [...records.values()]
.map((record) => ({
...record,
rawLabels: [...record.rawLabels].sort((left, right) => left.localeCompare(right)),
layerRangeText: layerRangeText(record),
pathMm: roundedZ(record.pathMm),
extrusionMm: roundedZ(record.extrusionMm)
}))
.sort((left, right) => {
const categoryDelta = (FEATURE_CATEGORY_ORDER[left.category] ?? 99) - (FEATURE_CATEGORY_ORDER[right.category] ?? 99);
if (categoryDelta !== 0) {
return categoryDelta;
}
const extrusionDelta = right.extrusionMoves - left.extrusionMoves;
if (extrusionDelta !== 0) {
return extrusionDelta;
}
return left.label.localeCompare(right.label);
});
}
function warningList(warnings, unsupportedCommands) {
const result = [...warnings];
const unsupported = [...unsupportedCommands.entries()]
.sort((left, right) => left[0].localeCompare(right[0]))
.slice(0, 12)
.map(([command, line]) => `${command} line ${line}`);
if (unsupported.length) {
result.push(`Unsupported or ignored commands were left out of the preview: ${unsupported.join(", ")}.`);
}
return result;
}
export function parseGcode(text, { sourceUrl = "", fileRef = "" } = {}) {
const lines = String(text || "").split(/\r?\n/);
const segments = [];
const unsupportedCommands = new Map();
const warnings = [];
const bounds = {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity]
};
const stats = {
lines: lines.length,
nonCommentLines: 0,
movementCommands: 0,
extrusionMoves: 0,
travelMoves: 0,
retractMoves: 0,
primeMoves: 0,
temperatureCommands: 0,
extrusionMm: 0,
pathMm: 0
};
const position = {
x: 0,
y: 0,
z: 0,
e: 0
};
let absolutePositioning = true;
let absoluteExtrusion = true;
let units = "mm";
let relativeWarningAdded = false;
let inchWarningAdded = false;
let currentFeature = null;
const featureMarkers = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const lineNumber = lineIndex + 1;
const rawLine = lines[lineIndex];
const featureMarker = parseFeatureMarker(rawLine);
if (featureMarker) {
currentFeature = featureMarker;
featureMarkers.push({
lineNumber,
feature: featureMarker
});
}
const command = parseCommand(rawLine);
if (!command) {
continue;
}
stats.nonCommentLines += 1;
const tokens = parseTokens(rawLine);
if (!SUPPORTED_COMMANDS.has(command) && !/^T\d+$/.test(command)) {
unsupportedCommands.set(command, unsupportedCommands.get(command) || lineNumber);
}
if (command === "G20") {
units = "inch";
if (!inchWarningAdded) {
warnings.push("G20 inch units found; preview geometry assumes millimeters.");
inchWarningAdded = true;
}
continue;
}
if (command === "G21") {
units = "mm";
continue;
}
if (command === "G90") {
absolutePositioning = true;
continue;
}
if (command === "G91") {
absolutePositioning = false;
if (!relativeWarningAdded) {
warnings.push("Relative XYZ positioning found; preview follows relative moves after G91.");
relativeWarningAdded = true;
}
continue;
}
if (command === "M82") {
absoluteExtrusion = true;
continue;
}
if (command === "M83") {
absoluteExtrusion = false;
continue;
}
if (command === "G92") {
if (Object.hasOwn(tokens, "X")) {
position.x = tokens.X;
}
if (Object.hasOwn(tokens, "Y")) {
position.y = tokens.Y;
}
if (Object.hasOwn(tokens, "Z")) {
position.z = tokens.Z;
}
if (Object.hasOwn(tokens, "E")) {
position.e = tokens.E;
}
continue;
}
if (["M104", "M109", "M140", "M190"].includes(command)) {
stats.temperatureCommands += 1;
continue;
}
if (!["G0", "G1", "G2", "G3"].includes(command)) {
continue;
}
const start = [position.x, position.y, position.z];
const end = endpointForMove(position, tokens, absolutePositioning);
const nextE = nextExtruder(position, tokens, absoluteExtrusion);
const extrusionDelta = nextE - position.e;
const feedRate = Object.hasOwn(tokens, "F") ? tokens.F : null;
if (command === "G2" || command === "G3") {
const points = arcSegments({
start,
end,
tokens,
clockwise: command === "G2"
});
if (points) {
for (let index = 0; index < points.length - 1; index += 1) {
addSegment(segments, bounds, stats, {
lineNumber,
command,
start: points[index],
end: points[index + 1],
extrusionDelta: extrusionDelta / Math.max(points.length - 1, 1),
feedRate,
arc: true,
feature: currentFeature
});
}
} else {
warnings.push(`Line ${lineNumber}: ${command} arc is missing I/J center offsets; previewed as a straight move.`);
addSegment(segments, bounds, stats, {
lineNumber,
command,
start,
end,
extrusionDelta,
feedRate,
arc: false,
feature: currentFeature
});
}
} else {
addSegment(segments, bounds, stats, {
lineNumber,
command,
start,
end,
extrusionDelta,
feedRate,
feature: currentFeature
});
}
position.x = end[0];
position.y = end[1];
position.z = end[2];
position.e = nextE;
}
const layers = finalizeLayers(segments);
const features = finalizeFeatures(segments, featureMarkers);
return {
schemaVersion: GCODE_RENDER_SCHEMA_VERSION,
sourceUrl,
fileRef,
units,
bounds: compactBounds(bounds),
layers,
features,
segments,
stats,
warnings: warningList(warnings, unsupportedCommands)
};
}
@@ -0,0 +1,30 @@
import path from "node:path";
export function toPosixPath(value) {
return String(value || "").split(path.sep).join("/");
}
export function encodePathParam(value) {
return toPosixPath(value)
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
}
function relativePathStaysInsideRoot(relativePath) {
return relativePath === "" || (
relativePath !== ".." &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)
);
}
export function pathIsInside(childPath, parentPath) {
const relativePath = path.relative(path.resolve(parentPath), path.resolve(childPath));
return Boolean(relativePath) && relativePathStaysInsideRoot(relativePath);
}
export function pathIsInsideOrEqual(childPath, parentPath) {
const relativePath = path.relative(path.resolve(parentPath), path.resolve(childPath));
return relativePathStaysInsideRoot(relativePath);
}
@@ -0,0 +1,163 @@
import {
cloneCameraVector,
normalizeCameraZoom
} from "../common/camera.js";
function normalizePerspectiveMetadataValue(value) {
return String(value || "").trim();
}
export const CAMERA_PROJECTION = Object.freeze({
PERSPECTIVE: "perspective",
ORTHOGRAPHIC: "orthographic"
});
export function normalizeCameraProjection(value, fallback = CAMERA_PROJECTION.PERSPECTIVE) {
const normalizedValue = String(value || "").trim().toLowerCase();
if (normalizedValue === CAMERA_PROJECTION.ORTHOGRAPHIC) {
return CAMERA_PROJECTION.ORTHOGRAPHIC;
}
if (normalizedValue === CAMERA_PROJECTION.PERSPECTIVE) {
return CAMERA_PROJECTION.PERSPECTIVE;
}
return fallback === CAMERA_PROJECTION.ORTHOGRAPHIC
? CAMERA_PROJECTION.ORTHOGRAPHIC
: CAMERA_PROJECTION.PERSPECTIVE;
}
export function clonePerspectiveVector(vector) {
return cloneCameraVector(vector);
}
export function clonePerspectiveSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object") {
return null;
}
const position = clonePerspectiveVector(snapshot.position);
const target = clonePerspectiveVector(snapshot.target);
const up = clonePerspectiveVector(snapshot.up);
if (!position || !target || !up) {
return null;
}
const clonedSnapshot = {
position,
target,
up
};
if (Object.prototype.hasOwnProperty.call(snapshot, "zoom")) {
clonedSnapshot.zoom = normalizeCameraZoom(snapshot.zoom, 1);
}
if (Object.prototype.hasOwnProperty.call(snapshot, "projection")) {
clonedSnapshot.projection = normalizeCameraProjection(snapshot.projection);
}
const modelKey = normalizePerspectiveMetadataValue(snapshot.modelKey);
const sceneScaleMode = normalizePerspectiveMetadataValue(snapshot.sceneScaleMode);
const coordinateSystem = normalizePerspectiveMetadataValue(snapshot.coordinateSystem);
if (modelKey) {
clonedSnapshot.modelKey = modelKey;
}
if (sceneScaleMode) {
clonedSnapshot.sceneScaleMode = sceneScaleMode;
}
if (coordinateSystem) {
clonedSnapshot.coordinateSystem = coordinateSystem;
}
return clonedSnapshot;
}
export function annotatePerspectiveSnapshot(snapshot, { modelKey = "", sceneScaleMode = "", coordinateSystem = "" } = {}) {
const annotatedSnapshot = clonePerspectiveSnapshot(snapshot);
if (!annotatedSnapshot) {
return null;
}
const normalizedModelKey = normalizePerspectiveMetadataValue(modelKey);
const normalizedSceneScaleMode = normalizePerspectiveMetadataValue(sceneScaleMode);
const normalizedCoordinateSystem = normalizePerspectiveMetadataValue(coordinateSystem);
if (normalizedModelKey) {
annotatedSnapshot.modelKey = normalizedModelKey;
}
if (normalizedSceneScaleMode) {
annotatedSnapshot.sceneScaleMode = normalizedSceneScaleMode;
}
if (normalizedCoordinateSystem) {
annotatedSnapshot.coordinateSystem = normalizedCoordinateSystem;
}
return annotatedSnapshot;
}
export function resolvePerspectiveSnapshot(primary, fallback) {
if (typeof primary !== "undefined") {
return clonePerspectiveSnapshot(primary);
}
return clonePerspectiveSnapshot(fallback);
}
function perspectiveVectorEqual(a, b, epsilon = 1e-4) {
if (a === b) {
return true;
}
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return false;
}
for (let index = 0; index < a.length; index += 1) {
if (Math.abs((Number(a[index]) || 0) - (Number(b[index]) || 0)) > epsilon) {
return false;
}
}
return true;
}
export function perspectiveSnapshotEqual(a, b, epsilon = 1e-4) {
if (a === b) {
return true;
}
if (!a || !b) {
return !a && !b;
}
return (
perspectiveVectorEqual(a.position, b.position, epsilon) &&
perspectiveVectorEqual(a.target, b.target, epsilon) &&
perspectiveVectorEqual(a.up, b.up, epsilon) &&
Math.abs(normalizeCameraZoom(a.zoom, 1) - normalizeCameraZoom(b.zoom, 1)) <= epsilon &&
normalizeCameraProjection(a.projection) === normalizeCameraProjection(b.projection) &&
normalizePerspectiveMetadataValue(a.modelKey) === normalizePerspectiveMetadataValue(b.modelKey) &&
normalizePerspectiveMetadataValue(a.sceneScaleMode) === normalizePerspectiveMetadataValue(b.sceneScaleMode) &&
normalizePerspectiveMetadataValue(a.coordinateSystem) === normalizePerspectiveMetadataValue(b.coordinateSystem)
);
}
export function perspectiveSnapshotMatchesScene(snapshot, {
modelKey = "",
sceneScaleMode = "",
coordinateSystem = "",
requireModelKey = false,
requireSceneScaleMode = false,
requireCoordinateSystem = false
} = {}) {
const normalizedSnapshot = clonePerspectiveSnapshot(snapshot);
if (!normalizedSnapshot) {
return false;
}
const normalizedModelKey = normalizePerspectiveMetadataValue(modelKey);
if (requireModelKey && normalizedModelKey && normalizedSnapshot.modelKey !== normalizedModelKey) {
return false;
}
if (normalizedModelKey && normalizedSnapshot.modelKey && normalizedSnapshot.modelKey !== normalizedModelKey) {
return false;
}
const normalizedSceneScaleMode = normalizePerspectiveMetadataValue(sceneScaleMode);
if (requireSceneScaleMode && normalizedSceneScaleMode && normalizedSnapshot.sceneScaleMode !== normalizedSceneScaleMode) {
return false;
}
if (normalizedSceneScaleMode && normalizedSnapshot.sceneScaleMode && normalizedSnapshot.sceneScaleMode !== normalizedSceneScaleMode) {
return false;
}
const normalizedCoordinateSystem = normalizePerspectiveMetadataValue(coordinateSystem);
if (requireCoordinateSystem && normalizedCoordinateSystem && normalizedSnapshot.coordinateSystem !== normalizedCoordinateSystem) {
return false;
}
if (normalizedCoordinateSystem && normalizedSnapshot.coordinateSystem && normalizedSnapshot.coordinateSystem !== normalizedCoordinateSystem) {
return false;
}
return true;
}
@@ -0,0 +1,492 @@
const GLB_CAD_UNIT_SCALE = 1000;
const CAD_EDGE_BARYCENTRIC_ATTRIBUTE_NAMES = Object.freeze([
"_cad_edge_barycentric",
"_CAD_EDGE_BARYCENTRIC"
]);
const CAD_EDGE_CLASS_ATTRIBUTE_NAMES = Object.freeze([
"_cad_edge_class",
"_CAD_EDGE_CLASS"
]);
const GENERATED_STEP_DEFAULT_BASE_COLOR = Object.freeze([0.72, 0.72, 0.72, 1]);
const BASE_COLOR_EPSILON = 1e-6;
function createBoundsAccumulator() {
return {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity],
};
}
function includeBoundsPoint(bounds, x, y, z) {
if (![x, y, z].every(Number.isFinite)) {
return;
}
bounds.min[0] = Math.min(bounds.min[0], x);
bounds.min[1] = Math.min(bounds.min[1], y);
bounds.min[2] = Math.min(bounds.min[2], z);
bounds.max[0] = Math.max(bounds.max[0], x);
bounds.max[1] = Math.max(bounds.max[1], y);
bounds.max[2] = Math.max(bounds.max[2], z);
}
function boundsFromAccumulator(bounds) {
if (!bounds?.min?.every(Number.isFinite) || !bounds?.max?.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
return {
min: [...bounds.min],
max: [...bounds.max],
};
}
function rawBaseColorFactor(rawMaterial) {
const value = rawMaterial?.pbrMetallicRoughness?.baseColorFactor;
return Array.isArray(value) ? value : null;
}
function colorFactorsEqual(left, right) {
if (!Array.isArray(left) || !Array.isArray(right)) {
return false;
}
return right.every((expected, index) => (
Math.abs(Number(left[index] ?? (index === 3 ? 1 : 0)) - expected) <= BASE_COLOR_EPSILON
));
}
function rawMaterialSourceColorHint(rawMaterial) {
const value = rawMaterial?.extras?.cadSourceColor;
return value === true || value === false ? value : null;
}
function materialSourceColorHint(material) {
const value = material?.userData?.cadSourceColor;
return value === true || value === false ? value : null;
}
function isGeneratedStepDefaultMaterial(rawMaterial, material) {
return (
colorFactorsEqual(rawBaseColorFactor(rawMaterial), GENERATED_STEP_DEFAULT_BASE_COLOR) &&
material?.color?.getHexString?.() === "dddddd"
);
}
function colorFromMaterial(material, useSourceColors, {
rawMaterial = null,
stepTopology = false
} = {}) {
if (!useSourceColors || !material?.color) {
return null;
}
const sourceColorHint = materialSourceColorHint(material) ?? rawMaterialSourceColorHint(rawMaterial);
if (sourceColorHint === false) {
return null;
}
if (stepTopology && sourceColorHint !== true && isGeneratedStepDefaultMaterial(rawMaterial, material)) {
return null;
}
return {
rgb: [material.color.r, material.color.g, material.color.b],
hex: `#${material.color.getHexString()}`,
opacity: materialOpacity(material, rawMaterial),
};
}
function materialOpacity(material, rawMaterial = null) {
const materialValue = Number(material?.opacity);
if (Number.isFinite(materialValue)) {
return Math.min(Math.max(materialValue, 0), 1);
}
const rawAlpha = rawBaseColorFactor(rawMaterial)?.[3];
const rawValue = Number(rawAlpha);
return Number.isFinite(rawValue) ? Math.min(Math.max(rawValue, 0), 1) : 1;
}
function materialForGroup(material, group) {
if (Array.isArray(material)) {
const materialIndex = Number.isInteger(group?.materialIndex) ? group.materialIndex : 0;
return material[materialIndex] || material[0] || null;
}
return material || null;
}
function materialIndexForGroup(group) {
return Number.isInteger(group?.materialIndex) ? group.materialIndex : 0;
}
function rawMaterialForGroup(rawMaterials, group) {
if (!Array.isArray(rawMaterials) || rawMaterials.length <= 0) {
return null;
}
return rawMaterials[materialIndexForGroup(group)] || rawMaterials[0] || null;
}
function isBuild123dAxisCorrectionMatrix(matrix) {
const elements = matrix?.elements;
if (!Array.isArray(elements) && !(elements instanceof Float32Array)) {
return false;
}
const expected = [
1, 0, 0, 0,
0, 0, -1, 0,
0, 1, 0, 0,
0, 0, 0, 1,
];
return expected.every((value, index) => Math.abs(Number(elements[index]) - value) < 1e-6);
}
function buildGlbCadRootCorrection(THREE, scene) {
scene?.updateWorldMatrix?.(true, true);
const children = Array.isArray(scene?.children) ? scene.children : [];
if (children.length !== 1 || !isBuild123dAxisCorrectionMatrix(children[0]?.matrixWorld)) {
return null;
}
return new THREE.Matrix4().copy(children[0].matrixWorld).invert();
}
function cadOccurrenceIdForObject(object) {
let current = object || null;
while (current) {
const rawOccurrenceId = String(current.userData?.cadOccurrenceId || "").trim();
if (rawOccurrenceId) {
return rawOccurrenceId;
}
current = current.parent || null;
}
const objectName = String(object?.name || "").trim();
return /^o\d+(?:\.\d+)*$/.test(objectName) ? objectName : "";
}
function sceneHasCadOccurrenceIds(scene) {
let found = false;
scene?.traverse?.((object) => {
if (found) {
return;
}
found = Boolean(String(object?.userData?.cadOccurrenceId || "").trim());
});
return found;
}
function cadVectorFromGlbVector(vector, convertYUpToCad) {
const x = vector.x * GLB_CAD_UNIT_SCALE;
const y = vector.y * GLB_CAD_UNIT_SCALE;
const z = vector.z * GLB_CAD_UNIT_SCALE;
return convertYUpToCad
? { x, y: -z, z: y }
: { x, y, z };
}
function sourceIndexForSlot(indexAttribute, slot) {
return indexAttribute ? indexAttribute.getX(slot) : slot;
}
function geometryAttributeByName(geometry, names) {
for (const name of names) {
const attribute = geometry?.getAttribute?.(name);
if (attribute) {
return attribute;
}
}
return null;
}
function isValidTriangleSource(positions, indexAttribute, sourceStart) {
for (let offset = 0; offset < 3; offset += 1) {
const sourceIndex = sourceIndexForSlot(indexAttribute, sourceStart + offset);
if (sourceIndex < 0 || sourceIndex >= positions.count) {
return false;
}
}
return true;
}
function countPrimitiveOutputVertices(positions, indexAttribute, sourceStart, triangleVertexCount) {
let vertexCount = 0;
for (let localIndex = 0; localIndex < triangleVertexCount; localIndex += 3) {
if (isValidTriangleSource(positions, indexAttribute, sourceStart + localIndex)) {
vertexCount += 3;
}
}
return vertexCount;
}
function inspectGlbPrimitive(
THREE,
mesh,
group,
material,
useSourceColors,
rootCorrection,
convertYUpToCad,
primitiveIndex = 0,
partIndex = 0,
{ rawMaterial = null, stepTopology = false } = {}
) {
const geometry = mesh?.geometry;
const positions = geometry?.getAttribute?.("position");
if (!positions || positions.itemSize !== 3 || positions.count <= 0) {
return null;
}
mesh.updateWorldMatrix?.(true, false);
const matrixWorld = mesh.matrixWorld
? (
rootCorrection
? new THREE.Matrix4().multiplyMatrices(rootCorrection, mesh.matrixWorld)
: new THREE.Matrix4().copy(mesh.matrixWorld)
)
: null;
const normalMatrix = matrixWorld ? new THREE.Matrix3().getNormalMatrix(matrixWorld) : null;
const normals = geometry.getAttribute("normal");
const surfaceEdgeBarycentric = geometryAttributeByName(geometry, CAD_EDGE_BARYCENTRIC_ATTRIBUTE_NAMES);
const surfaceEdgeClass = geometryAttributeByName(geometry, CAD_EDGE_CLASS_ATTRIBUTE_NAMES);
const indexAttribute = geometry.getIndex?.();
const sourceStart = Math.max(0, Math.floor(Number(group?.start || 0)));
const availableCount = indexAttribute?.count || positions.count;
const rawCount = Math.floor(Number(group?.count || (availableCount - sourceStart)));
const sourceCount = Math.max(0, Math.min(rawCount, availableCount - sourceStart));
const triangleVertexCount = sourceCount - (sourceCount % 3);
if (triangleVertexCount <= 0) {
return null;
}
const vertexCount = countPrimitiveOutputVertices(positions, indexAttribute, sourceStart, triangleVertexCount);
const triangleCount = Math.floor(vertexCount / 3);
if (vertexCount <= 0 || triangleCount <= 0) {
return null;
}
const color = colorFromMaterial(material, useSourceColors, {
rawMaterial,
stepTopology
});
const cadOccurrenceId = cadOccurrenceIdForObject(mesh);
const label = String(cadOccurrenceId || mesh?.name || mesh?.parent?.name || `glb:${partIndex}`).trim();
const id = cadOccurrenceId || `glb:${partIndex}`;
return {
mesh,
positions,
normals,
surfaceEdgeBarycentric,
surfaceEdgeClass,
indexAttribute,
sourceStart,
triangleVertexCount,
matrixWorld,
normalMatrix,
convertYUpToCad,
id,
occurrenceId: id,
primitiveIndex: Math.max(0, Math.floor(Number(primitiveIndex) || 0)),
name: label || id,
label: label || id,
color: color?.hex || "",
opacity: color ? color.opacity : 1,
hasSourceColors: Boolean(color),
vertexCount,
triangleCount,
};
}
function writeGlbPrimitive(THREE, descriptor, output, offsets) {
const partBounds = createBoundsAccumulator();
const positionVector = new THREE.Vector3();
const normalVector = new THREE.Vector3();
const vertexOffset = offsets.vertexOffset;
const triangleOffset = Math.floor(offsets.indexOffset / 3);
let localVertexCount = 0;
for (let localIndex = 0; localIndex < descriptor.triangleVertexCount; localIndex += 3) {
if (!isValidTriangleSource(
descriptor.positions,
descriptor.indexAttribute,
descriptor.sourceStart + localIndex
)) {
continue;
}
for (let triangleOffsetIndex = 0; triangleOffsetIndex < 3; triangleOffsetIndex += 1) {
const sourceSlot = descriptor.sourceStart + localIndex + triangleOffsetIndex;
const sourceIndex = sourceIndexForSlot(descriptor.indexAttribute, sourceSlot);
const outputVertexIndex = vertexOffset + localVertexCount;
const outputComponentIndex = outputVertexIndex * 3;
positionVector.set(
descriptor.positions.getX(sourceIndex),
descriptor.positions.getY(sourceIndex),
descriptor.positions.getZ(sourceIndex)
);
if (descriptor.matrixWorld) {
positionVector.applyMatrix4(descriptor.matrixWorld);
}
const cadPosition = cadVectorFromGlbVector(positionVector, descriptor.convertYUpToCad);
const x = cadPosition.x;
const y = cadPosition.y;
const z = cadPosition.z;
output.vertices[outputComponentIndex] = x;
output.vertices[outputComponentIndex + 1] = y;
output.vertices[outputComponentIndex + 2] = z;
includeBoundsPoint(partBounds, x, y, z);
includeBoundsPoint(output.bounds, x, y, z);
if (descriptor.normals?.itemSize === 3 && sourceIndex < descriptor.normals.count) {
normalVector.set(
descriptor.normals.getX(sourceIndex),
descriptor.normals.getY(sourceIndex),
descriptor.normals.getZ(sourceIndex)
);
if (descriptor.normalMatrix) {
normalVector.applyMatrix3(descriptor.normalMatrix).normalize();
}
const cadNormal = descriptor.convertYUpToCad
? { x: normalVector.x, y: -normalVector.z, z: normalVector.y }
: normalVector;
output.normals[outputComponentIndex] = cadNormal.x;
output.normals[outputComponentIndex + 1] = cadNormal.y;
output.normals[outputComponentIndex + 2] = cadNormal.z;
}
if (output.surfaceEdgeBarycentric && descriptor.surfaceEdgeBarycentric?.itemSize === 3 && sourceIndex < descriptor.surfaceEdgeBarycentric.count) {
output.surfaceEdgeBarycentric[outputComponentIndex] = descriptor.surfaceEdgeBarycentric.getX(sourceIndex);
output.surfaceEdgeBarycentric[outputComponentIndex + 1] = descriptor.surfaceEdgeBarycentric.getY(sourceIndex);
output.surfaceEdgeBarycentric[outputComponentIndex + 2] = descriptor.surfaceEdgeBarycentric.getZ(sourceIndex);
}
if (output.surfaceEdgeClass && descriptor.surfaceEdgeClass?.itemSize === 3 && sourceIndex < descriptor.surfaceEdgeClass.count) {
output.surfaceEdgeClass[outputComponentIndex] = descriptor.surfaceEdgeClass.getX(sourceIndex);
output.surfaceEdgeClass[outputComponentIndex + 1] = descriptor.surfaceEdgeClass.getY(sourceIndex);
output.surfaceEdgeClass[outputComponentIndex + 2] = descriptor.surfaceEdgeClass.getZ(sourceIndex);
}
output.indices[offsets.indexOffset + localVertexCount] = outputVertexIndex;
localVertexCount += 1;
}
}
const triangleCount = Math.floor(localVertexCount / 3);
const part = {
id: descriptor.id,
occurrenceId: descriptor.occurrenceId,
primitiveIndex: descriptor.primitiveIndex,
name: descriptor.name,
label: descriptor.label,
nodeType: "part",
color: descriptor.color,
opacity: descriptor.opacity,
hasSourceColors: descriptor.hasSourceColors,
bounds: boundsFromAccumulator(partBounds),
vertexOffset,
vertexCount: localVertexCount,
triangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0,
};
offsets.vertexOffset += localVertexCount;
offsets.indexOffset += triangleCount * 3;
return part;
}
function buildMeshDataFromGltf(THREE, gltf) {
const declaredMaterials = Array.isArray(gltf?.parser?.json?.materials) && gltf.parser.json.materials.length > 0;
const rawMaterials = Array.isArray(gltf?.parser?.json?.materials) ? gltf.parser.json.materials : [];
const rootCorrection = buildGlbCadRootCorrection(THREE, gltf?.scene);
const hasStepTopology = !!gltf?.parser?.json?.extensions?.STEP_topology;
const convertYUpToCad = !hasStepTopology && !sceneHasCadOccurrenceIds(gltf?.scene) && !rootCorrection;
const descriptors = [];
const colorSet = new Set();
let totalVertexCount = 0;
let totalIndexCount = 0;
let hasSurfaceEdgeAttributes = false;
const nextPrimitiveIndexByOccurrence = new Map();
gltf?.scene?.traverse?.((object) => {
if (!object?.isMesh || !object.geometry) {
return;
}
const occurrenceId = cadOccurrenceIdForObject(object) || String(object?.name || `glb:${descriptors.length}`).trim();
const primitiveIndexBase = nextPrimitiveIndexByOccurrence.get(occurrenceId) || 0;
const groups = Array.isArray(object.geometry.groups) && object.geometry.groups.length
? object.geometry.groups
: [null];
groups.forEach((group, primitiveIndex) => {
const descriptor = inspectGlbPrimitive(
THREE,
object,
group,
materialForGroup(object.material, group),
declaredMaterials,
rootCorrection,
convertYUpToCad,
primitiveIndexBase + primitiveIndex,
descriptors.length,
{
rawMaterial: rawMaterialForGroup(rawMaterials, group),
stepTopology: hasStepTopology
}
);
if (!descriptor) {
return;
}
descriptors.push(descriptor);
totalVertexCount += descriptor.vertexCount;
totalIndexCount += descriptor.triangleCount * 3;
hasSurfaceEdgeAttributes ||= Boolean(descriptor.surfaceEdgeBarycentric && descriptor.surfaceEdgeClass);
if (descriptor.color) {
colorSet.add(descriptor.color.toLowerCase());
}
});
nextPrimitiveIndexByOccurrence.set(occurrenceId, primitiveIndexBase + groups.length);
});
const vertices = new Float32Array(totalVertexCount * 3);
const indices = new Uint32Array(totalIndexCount);
const normals = new Float32Array(totalVertexCount * 3);
const surfaceEdgeBarycentric = hasSurfaceEdgeAttributes
? new Float32Array(totalVertexCount * 3)
: null;
const surfaceEdgeClass = hasSurfaceEdgeAttributes
? new Uint8Array(totalVertexCount * 3)
: null;
const parts = [];
const output = {
vertices,
indices,
normals,
surfaceEdgeBarycentric,
surfaceEdgeClass,
bounds: createBoundsAccumulator(),
};
const offsets = {
vertexOffset: 0,
indexOffset: 0,
};
for (const descriptor of descriptors) {
parts.push(writeGlbPrimitive(THREE, descriptor, output, offsets));
}
const colors = new Float32Array(0);
return {
vertices,
indices,
normals,
surfaceEdgeBarycentric: surfaceEdgeBarycentric || new Float32Array(0),
surfaceEdgeClass: surfaceEdgeClass || new Uint8Array(0),
colors,
edge_indices: new Uint32Array(0),
bounds: boundsFromAccumulator(output.bounds),
parts,
has_source_colors: colorSet.size > 0,
sourceColor: colorSet.size === 1 ? [...colorSet][0] : "",
};
}
function parseGlb(GLTFLoader, buffer) {
const loader = new GLTFLoader();
return new Promise((resolve, reject) => {
loader.parse(buffer, "", resolve, reject);
});
}
export async function buildMeshDataFromGlbBuffer(buffer) {
const [THREE, { GLTFLoader }] = await Promise.all([
import("three"),
import("three/examples/jsm/loaders/GLTFLoader.js"),
]);
const gltf = await parseGlb(GLTFLoader, buffer);
return buildMeshDataFromGltf(THREE, gltf);
}
@@ -0,0 +1,61 @@
import { buildMeshDataFromGlbBuffer } from "./glbMeshData.js";
import { meshDataTransferList } from "./meshTransfer.js";
const activeControllers = new Map();
function fetchError(url, response) {
return new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
async function loadArrayBuffer(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.arrayBuffer();
}
self.addEventListener("message", async (event) => {
const message = event.data || {};
const id = message.id;
if (!id) {
return;
}
if (message.type === "cancel") {
activeControllers.get(id)?.abort();
activeControllers.delete(id);
return;
}
if (message.type !== "loadGlb") {
return;
}
const controller = new AbortController();
activeControllers.set(id, controller);
try {
const buffer = await loadArrayBuffer(message.url, controller.signal);
const meshData = await buildMeshDataFromGlbBuffer(buffer);
if (controller.signal.aborted) {
return;
}
self.postMessage(
{ id, ok: true, meshData },
meshDataTransferList(meshData)
);
} catch (error) {
if (!controller.signal.aborted) {
self.postMessage({
id,
ok: false,
error: {
name: error?.name || "Error",
message: error instanceof Error ? error.message : String(error)
}
});
}
} finally {
activeControllers.delete(id);
}
});
@@ -0,0 +1,94 @@
let glbWorker = null;
let nextRequestId = 1;
const pendingRequests = new Map();
function makeAbortError() {
if (typeof DOMException === "function") {
return new DOMException("The operation was aborted.", "AbortError");
}
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
function glbWorkerSupported() {
return typeof Worker === "function" && typeof URL === "function";
}
function rejectPendingRequests(error) {
for (const request of pendingRequests.values()) {
request.cleanup();
request.reject(error);
}
pendingRequests.clear();
}
function ensureGlbWorker() {
if (!glbWorkerSupported()) {
return null;
}
if (glbWorker) {
return glbWorker;
}
try {
glbWorker = new Worker(new URL("./glbMeshWorker.js", import.meta.url), { type: "module" });
} catch {
glbWorker = null;
return null;
}
glbWorker.addEventListener("message", (event) => {
const message = event.data || {};
const request = pendingRequests.get(message.id);
if (!request) {
return;
}
pendingRequests.delete(message.id);
request.cleanup();
if (message.ok) {
request.resolve(message.meshData);
return;
}
const error = new Error(message.error?.message || "Failed to load GLB mesh in worker.");
error.name = message.error?.name || "Error";
request.reject(error);
});
glbWorker.addEventListener("error", (event) => {
const error = new Error(event?.message || "GLB mesh worker failed.");
rejectPendingRequests(error);
glbWorker?.terminate?.();
glbWorker = null;
});
return glbWorker;
}
export function loadGlbMeshDataInWorker(url, { signal } = {}) {
const worker = ensureGlbWorker();
if (!worker) {
return null;
}
if (signal?.aborted) {
return Promise.reject(makeAbortError());
}
const id = nextRequestId;
nextRequestId += 1;
return new Promise((resolve, reject) => {
const cleanup = () => {
signal?.removeEventListener?.("abort", abort);
};
const abort = () => {
pendingRequests.delete(id);
cleanup();
worker.postMessage({ type: "cancel", id });
reject(makeAbortError());
};
pendingRequests.set(id, {
resolve,
reject,
cleanup
});
signal?.addEventListener?.("abort", abort, { once: true });
worker.postMessage({ type: "loadGlb", id, url });
});
}
@@ -0,0 +1,68 @@
import {
entryAssetBytes,
entryMeshAssetBytes
} from "../entryAssets.js";
import {
entrySourceFormat,
RENDER_FORMAT
} from "../fileFormats.js";
export const LARGE_STEP_GLB_BYTES = 32 * 1024 * 1024;
export const LARGE_MESH_TRIANGLE_COUNT = 1_000_000;
export const LARGE_MESH_TYPED_ARRAY_BYTES = 160 * 1024 * 1024;
function typedArrayBytes(value) {
return ArrayBuffer.isView(value) ? Number(value.byteLength) || 0 : 0;
}
export function entryStepGlbBytes(entry) {
return entrySourceFormat(entry) === RENDER_FORMAT.STEP
? entryAssetBytes(entry, "glb")
: 0;
}
export function hasStepGlbByteCost(entry) {
return entryStepGlbBytes(entry) > 0;
}
export function isLargeStepGlbEntry(entry) {
return entryStepGlbBytes(entry) >= LARGE_STEP_GLB_BYTES;
}
export function isLargeNativeGlbEntry(entry) {
return entrySourceFormat(entry) === RENDER_FORMAT.GLB &&
entryMeshAssetBytes(entry) >= LARGE_STEP_GLB_BYTES;
}
export function shouldUseGlbMeshWorkerForEntry(entry) {
return isLargeStepGlbEntry(entry) || isLargeNativeGlbEntry(entry);
}
export function estimateMeshRenderCost(meshData) {
const indicesTriangleCount = Math.floor((Number(meshData?.indices?.length) || 0) / 3);
const partTriangleCount = Array.isArray(meshData?.parts)
? meshData.parts.reduce((sum, part) => {
const triangleCount = Number(part?.triangleCount);
return sum + (Number.isFinite(triangleCount) && triangleCount > 0 ? triangleCount : 0);
}, 0)
: 0;
return {
triangleCount: Math.max(indicesTriangleCount, partTriangleCount),
typedArrayBytes: [
meshData?.vertices,
meshData?.indices,
meshData?.normals,
meshData?.colors,
meshData?.edge_indices,
meshData?.surfaceEdgeBarycentric,
meshData?.surfaceEdgeClass,
meshData?.guide_line_segments
].reduce((sum, value) => sum + typedArrayBytes(value), 0)
};
}
export function isLargeMeshData(meshData) {
const cost = estimateMeshRenderCost(meshData);
return cost.triangleCount >= LARGE_MESH_TRIANGLE_COUNT ||
cost.typedArrayBytes >= LARGE_MESH_TYPED_ARRAY_BYTES;
}
@@ -0,0 +1,78 @@
import {
renderFormatFromPath,
RENDER_FORMAT
} from "../fileFormats.js";
import {
loadRender3Mf,
loadRenderGlb,
loadRenderStl,
peekRender3Mf,
peekRenderGlb,
peekRenderStl
} from "../renderAssetClient.js";
function meshRenderFormat(format) {
return format === RENDER_FORMAT.STL || format === RENDER_FORMAT.THREE_MF || format === RENDER_FORMAT.GLB
? format
: "";
}
function renderFormatFromMeshAssetUrl(url) {
const directFormat = meshRenderFormat(renderFormatFromPath(url, {
baseUrl: typeof window !== "undefined" ? window.location.href : ""
}));
if (directFormat) {
return directFormat;
}
try {
const parsedUrl = new URL(url, typeof window !== "undefined" ? window.location.href : "http://localhost/");
for (const parameterName of ["file", "path"]) {
const parameterValue = parsedUrl.searchParams.get(parameterName);
const parameterFormat = meshRenderFormat(renderFormatFromPath(parameterValue || "", {
baseUrl: parsedUrl.href
}));
if (parameterFormat) {
return parameterFormat;
}
}
} catch {
// Fall through to the caller-provided fallback.
}
return "";
}
export function meshFormatFromUrl(url) {
return renderFormatFromMeshAssetUrl(url) || RENDER_FORMAT.GLB;
}
function normalizeMeshFallback(fallback) {
return fallback === RENDER_FORMAT.STL || fallback === RENDER_FORMAT.THREE_MF || fallback === RENDER_FORMAT.GLB
? fallback
: RENDER_FORMAT.GLB;
}
export function resolveMeshFormatFromUrl(url, { fallback = RENDER_FORMAT.GLB } = {}) {
return renderFormatFromMeshAssetUrl(url) || normalizeMeshFallback(fallback);
}
export function peekRenderMeshByUrl(url, options = {}) {
const format = resolveMeshFormatFromUrl(url, options);
if (format === RENDER_FORMAT.STL) {
return peekRenderStl(url);
}
if (format === RENDER_FORMAT.THREE_MF) {
return peekRender3Mf(url);
}
return peekRenderGlb(url);
}
export async function loadRenderMeshByUrl(url, options = {}) {
const format = resolveMeshFormatFromUrl(url, options);
if (format === RENDER_FORMAT.STL) {
return loadRenderStl(url, options);
}
if (format === RENDER_FORMAT.THREE_MF) {
return loadRender3Mf(url, options);
}
return loadRenderGlb(url, options);
}
@@ -0,0 +1,13 @@
export function meshDataTransferList(meshData) {
const buffers = [
meshData?.vertices?.buffer,
meshData?.indices?.buffer,
meshData?.normals?.buffer,
meshData?.colors?.buffer,
meshData?.edge_indices?.buffer,
meshData?.surfaceEdgeBarycentric?.buffer,
meshData?.surfaceEdgeClass?.buffer,
meshData?.guide_line_segments?.buffer
].filter((buffer) => buffer instanceof ArrayBuffer && buffer.byteLength > 0);
return [...new Set(buffers)];
}
@@ -0,0 +1,85 @@
function buildBoundsFromGeometry(geometry) {
geometry.computeBoundingBox();
const min = geometry.boundingBox?.min;
const max = geometry.boundingBox?.max;
if (!min || !max) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
return {
min: [min.x, min.y, min.z],
max: [max.x, max.y, max.z],
};
}
const STL_NORMAL_CREASE_ANGLE = Math.PI / 4;
function buildDisplayGeometryFromStlGeometry(geometry, toCreasedNormals) {
const displayGeometry = geometry.clone();
displayGeometry.deleteAttribute?.("normal");
const normalGeometry = typeof toCreasedNormals === "function"
? toCreasedNormals(displayGeometry, STL_NORMAL_CREASE_ANGLE)
: displayGeometry;
if (normalGeometry !== displayGeometry) {
displayGeometry.dispose?.();
}
if (!normalGeometry.getAttribute("normal")) {
normalGeometry.computeVertexNormals?.();
}
return normalGeometry;
}
function buildGeometryIndices(geometry, vertexCount) {
const indexAttribute = geometry.getIndex?.();
if (indexAttribute?.count > 0) {
return new Uint32Array(indexAttribute.array);
}
const indices = new Uint32Array(vertexCount);
for (let index = 0; index < vertexCount; index += 1) {
indices[index] = index;
}
return indices;
}
export function buildMeshDataFromStlGeometry(geometry, { toCreasedNormals } = {}) {
const displayGeometry = buildDisplayGeometryFromStlGeometry(geometry, toCreasedNormals);
const positions = geometry.getAttribute("position");
const displayPositions = displayGeometry.getAttribute("position");
const displayNormals = displayGeometry.getAttribute("normal");
try {
if (!positions || positions.itemSize !== 3 || !displayPositions || displayPositions.itemSize !== 3) {
throw new Error("STL geometry is missing vertex positions");
}
const vertexCount = displayPositions.count;
return {
vertices: new Float32Array(displayPositions.array),
indices: buildGeometryIndices(displayGeometry, vertexCount),
normals: displayNormals?.itemSize === 3 ? new Float32Array(displayNormals.array) : new Float32Array(displayPositions.array.length),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: buildBoundsFromGeometry(geometry),
parts: [],
has_source_colors: false,
sourceColor: "",
sourceFormat: "stl",
};
} finally {
displayGeometry.dispose?.();
}
}
export async function buildMeshDataFromStlBuffer(buffer) {
const [{ STLLoader }, { toCreasedNormals }] = await Promise.all([
import("three/examples/jsm/loaders/STLLoader.js"),
import("three/examples/jsm/utils/BufferGeometryUtils.js"),
]);
const loader = new STLLoader();
const geometry = loader.parse(buffer);
try {
return buildMeshDataFromStlGeometry(geometry, { toCreasedNormals });
} finally {
geometry.dispose?.();
}
}
@@ -0,0 +1,61 @@
import { buildMeshDataFromStlBuffer } from "./stlMeshData.js";
import { meshDataTransferList } from "./meshTransfer.js";
const activeControllers = new Map();
function fetchError(url, response) {
return new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
async function loadArrayBuffer(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.arrayBuffer();
}
self.addEventListener("message", async (event) => {
const message = event.data || {};
const id = message.id;
if (!id) {
return;
}
if (message.type === "cancel") {
activeControllers.get(id)?.abort();
activeControllers.delete(id);
return;
}
if (message.type !== "loadStl") {
return;
}
const controller = new AbortController();
activeControllers.set(id, controller);
try {
const buffer = await loadArrayBuffer(message.url, controller.signal);
const meshData = await buildMeshDataFromStlBuffer(buffer);
if (controller.signal.aborted) {
return;
}
self.postMessage(
{ id, ok: true, meshData },
meshDataTransferList(meshData)
);
} catch (error) {
if (!controller.signal.aborted) {
self.postMessage({
id,
ok: false,
error: {
name: error?.name || "Error",
message: error instanceof Error ? error.message : String(error)
}
});
}
} finally {
activeControllers.delete(id);
}
});
@@ -0,0 +1,94 @@
let stlWorker = null;
let nextRequestId = 1;
const pendingRequests = new Map();
function makeAbortError() {
if (typeof DOMException === "function") {
return new DOMException("The operation was aborted.", "AbortError");
}
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
function stlWorkerSupported() {
return typeof Worker === "function" && typeof URL === "function";
}
function rejectPendingRequests(error) {
for (const request of pendingRequests.values()) {
request.cleanup();
request.reject(error);
}
pendingRequests.clear();
}
function ensureStlWorker() {
if (!stlWorkerSupported()) {
return null;
}
if (stlWorker) {
return stlWorker;
}
try {
stlWorker = new Worker(new URL("./stlMeshWorker.js", import.meta.url), { type: "module" });
} catch {
stlWorker = null;
return null;
}
stlWorker.addEventListener("message", (event) => {
const message = event.data || {};
const request = pendingRequests.get(message.id);
if (!request) {
return;
}
pendingRequests.delete(message.id);
request.cleanup();
if (message.ok) {
request.resolve(message.meshData);
return;
}
const error = new Error(message.error?.message || "Failed to load STL mesh in worker.");
error.name = message.error?.name || "Error";
request.reject(error);
});
stlWorker.addEventListener("error", (event) => {
const error = new Error(event?.message || "STL mesh worker failed.");
rejectPendingRequests(error);
stlWorker?.terminate?.();
stlWorker = null;
});
return stlWorker;
}
export function loadStlMeshDataInWorker(url, { signal } = {}) {
const worker = ensureStlWorker();
if (!worker) {
return null;
}
if (signal?.aborted) {
return Promise.reject(makeAbortError());
}
const id = nextRequestId;
nextRequestId += 1;
return new Promise((resolve, reject) => {
const cleanup = () => {
signal?.removeEventListener?.("abort", abort);
};
const abort = () => {
pendingRequests.delete(id);
cleanup();
worker.postMessage({ type: "cancel", id });
reject(makeAbortError());
};
pendingRequests.set(id, {
resolve,
reject,
cleanup
});
signal?.addEventListener?.("abort", abort, { once: true });
worker.postMessage({ type: "loadStl", id, url });
});
}
@@ -0,0 +1,597 @@
const GENERATED_DEFAULT_MATERIAL_NAMES = new Set(["default"]);
const GENERATED_DEFAULT_MATERIAL_COLORS = new Set(["#fafafb", "#b6c4ce"]);
const WORKBENCH_DEFAULT_MATERIAL_COLOR = Object.freeze({
rgb: [0.4677837961, 0.5520114015, 0.6172065624],
hex: "#b6c4ce",
});
function buildBoundsFromVertices(vertices) {
if (!vertices?.length) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let index = 0; index + 2 < vertices.length; index += 3) {
const x = Number(vertices[index]);
const y = Number(vertices[index + 1]);
const z = Number(vertices[index + 2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
continue;
}
min[0] = Math.min(min[0], x);
min[1] = Math.min(min[1], y);
min[2] = Math.min(min[2], z);
max[0] = Math.max(max[0], x);
max[1] = Math.max(max[1], y);
max[2] = Math.max(max[2], z);
}
if (!min.every(Number.isFinite) || !max.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
return { min, max };
}
function normalize3MfPackagePath(value = "", basePath = "") {
const rawValue = String(value || "").trim().replace(/\\/g, "/");
if (!rawValue) {
return "";
}
if (rawValue.startsWith("/")) {
return rawValue.replace(/^\/+/, "");
}
const baseParts = String(basePath || "").split("/");
baseParts.pop();
const parts = [...baseParts, ...rawValue.split("/")];
const normalized = [];
for (const part of parts) {
if (!part || part === ".") {
continue;
}
if (part === "..") {
normalized.pop();
continue;
}
normalized.push(part);
}
return normalized.join("/");
}
function elementChildren(node, localName) {
return Array.from(node?.children || []).filter((child) => child.localName === localName);
}
function firstElementChild(node, localName) {
return elementChildren(node, localName)[0] || null;
}
function attributeByLocalName(node, localName) {
if (!node?.attributes) {
return "";
}
const direct = node.getAttribute?.(localName);
if (direct) {
return direct;
}
for (const attribute of Array.from(node.attributes)) {
if (attribute.localName === localName || String(attribute.name || "").split(":").pop() === localName) {
return attribute.value || "";
}
}
return "";
}
function parseNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function parseIndex(value) {
const numericValue = Number(value);
return Number.isInteger(numericValue) && numericValue >= 0 ? numericValue : -1;
}
function parse3MfXml(text, label) {
if (typeof DOMParser !== "function") {
throw new Error("3MF XML parsing is unavailable in this environment");
}
const document = new DOMParser().parseFromString(text, "application/xml");
const parseError = document.querySelector?.("parsererror");
if (parseError) {
throw new Error(`Invalid 3MF XML: ${label}`);
}
return document;
}
function parse3MfTransform(THREE, value) {
const transformValues = String(value || "")
.trim()
.split(/\s+/)
.map((item) => Number(item));
if (transformValues.length !== 12 || transformValues.some((item) => !Number.isFinite(item))) {
return null;
}
const matrix = new THREE.Matrix4();
matrix.set(
transformValues[0], transformValues[3], transformValues[6], transformValues[9],
transformValues[1], transformValues[4], transformValues[7], transformValues[10],
transformValues[2], transformValues[5], transformValues[8], transformValues[11],
0, 0, 0, 1
);
return matrix;
}
function parse3MfMeshNode(meshNode) {
const vertices = [];
const verticesNode = firstElementChild(meshNode, "vertices");
for (const vertexNode of elementChildren(verticesNode, "vertex")) {
vertices.push([
parseNumber(vertexNode.getAttribute("x")),
parseNumber(vertexNode.getAttribute("y")),
parseNumber(vertexNode.getAttribute("z")),
]);
}
const triangles = [];
const trianglesNode = firstElementChild(meshNode, "triangles");
for (const triangleNode of elementChildren(trianglesNode, "triangle")) {
const triangle = [
parseIndex(triangleNode.getAttribute("v1")),
parseIndex(triangleNode.getAttribute("v2")),
parseIndex(triangleNode.getAttribute("v3")),
];
if (triangle.every((index) => index >= 0)) {
triangles.push(triangle);
}
}
return { vertices, triangles };
}
function parse3MfModelPart(THREE, xmlText, modelPath) {
const document = parse3MfXml(xmlText, modelPath);
const modelNode = document.documentElement?.localName === "model"
? document.documentElement
: document.querySelector?.("model");
if (!modelNode) {
throw new Error(`3MF model part is missing a model node: ${modelPath}`);
}
const resourcesNode = firstElementChild(modelNode, "resources");
const objects = new Map();
for (const objectNode of elementChildren(resourcesNode, "object")) {
const id = String(objectNode.getAttribute("id") || "").trim();
if (!id) {
continue;
}
const name = String(objectNode.getAttribute("name") || objectNode.getAttribute("partnumber") || "").trim();
const meshNode = firstElementChild(objectNode, "mesh");
const componentsNode = firstElementChild(objectNode, "components");
const components = [];
for (const componentNode of elementChildren(componentsNode, "component")) {
const objectId = String(componentNode.getAttribute("objectid") || "").trim();
if (!objectId) {
continue;
}
components.push({
objectId,
path: normalize3MfPackagePath(attributeByLocalName(componentNode, "path"), modelPath),
transform: parse3MfTransform(THREE, componentNode.getAttribute("transform")),
});
}
objects.set(id, {
id,
name,
mesh: meshNode ? parse3MfMeshNode(meshNode) : null,
components,
});
}
const build = [];
const buildNode = firstElementChild(modelNode, "build");
for (const itemNode of elementChildren(buildNode, "item")) {
const objectId = String(itemNode.getAttribute("objectid") || "").trim();
if (!objectId) {
continue;
}
build.push({
objectId,
transform: parse3MfTransform(THREE, itemNode.getAttribute("transform")),
});
}
return {
path: modelPath,
objects,
build,
};
}
function parse3MfRelationships(xmlText, relsPath) {
const document = parse3MfXml(xmlText, relsPath);
return Array.from(document.querySelectorAll?.("Relationship") || [])
.map((node) => ({
target: normalize3MfPackagePath(node.getAttribute("Target")),
type: String(node.getAttribute("Type") || "").trim(),
}))
.filter((relationship) => relationship.target);
}
function colorFromMaterial(material) {
if (!material?.color) {
return null;
}
const hex = `#${material.color.getHexString()}`.toLowerCase();
const materialName = String(material.name || "").trim().toLowerCase();
const generatedDefault = GENERATED_DEFAULT_MATERIAL_NAMES.has(materialName) &&
GENERATED_DEFAULT_MATERIAL_COLORS.has(hex);
if (generatedDefault) {
return {
rgb: WORKBENCH_DEFAULT_MATERIAL_COLOR.rgb,
hex: WORKBENCH_DEFAULT_MATERIAL_COLOR.hex,
generatedDefault: true,
};
}
return {
rgb: [material.color.r, material.color.g, material.color.b],
hex,
generatedDefault: false,
};
}
function materialForGroup(material, group) {
if (Array.isArray(material)) {
const materialIndex = Number.isInteger(group?.materialIndex) ? group.materialIndex : 0;
return material[materialIndex] || material[0] || null;
}
return material || null;
}
function appendMeshPrimitive(THREE, accumulator, mesh, group, material) {
const geometry = mesh?.geometry;
const positions = geometry?.getAttribute?.("position");
if (!positions || positions.itemSize !== 3 || positions.count <= 0) {
return;
}
if (!geometry.getAttribute("normal")) {
geometry.computeVertexNormals?.();
}
mesh.updateWorldMatrix?.(true, false);
const matrixWorld = mesh.matrixWorld || null;
const normalMatrix = matrixWorld ? new THREE.Matrix3().getNormalMatrix(matrixWorld) : null;
const normals = geometry.getAttribute("normal");
const indexAttribute = geometry.getIndex?.();
const sourceStart = Math.max(0, Math.floor(Number(group?.start || 0)));
const availableCount = indexAttribute?.count || positions.count;
const rawCount = Math.floor(Number(group?.count || (availableCount - sourceStart)));
const sourceCount = Math.max(0, Math.min(rawCount, availableCount - sourceStart));
const triangleVertexCount = sourceCount - (sourceCount % 3);
if (triangleVertexCount <= 0) {
return;
}
const color = colorFromMaterial(material);
const positionVector = new THREE.Vector3();
const normalVector = new THREE.Vector3();
const vertexOffset = Math.floor(accumulator.vertices.length / 3);
const triangleOffset = Math.floor(accumulator.indices.length / 3);
const partVertices = [];
for (let localIndex = 0; localIndex < triangleVertexCount; localIndex += 1) {
const sourceSlot = sourceStart + localIndex;
const sourceIndex = indexAttribute ? indexAttribute.getX(sourceSlot) : sourceSlot;
if (sourceIndex < 0 || sourceIndex >= positions.count) {
continue;
}
const outputIndex = Math.floor(partVertices.length / 3);
positionVector.set(
positions.getX(sourceIndex),
positions.getY(sourceIndex),
positions.getZ(sourceIndex)
);
if (matrixWorld) {
positionVector.applyMatrix4(matrixWorld);
}
accumulator.vertices.push(positionVector.x, positionVector.y, positionVector.z);
partVertices.push(positionVector.x, positionVector.y, positionVector.z);
if (normals?.itemSize === 3 && sourceIndex < normals.count) {
normalVector.set(normals.getX(sourceIndex), normals.getY(sourceIndex), normals.getZ(sourceIndex));
if (normalMatrix) {
normalVector.applyMatrix3(normalMatrix).normalize();
}
accumulator.normals.push(normalVector.x, normalVector.y, normalVector.z);
} else {
accumulator.normals.push(0, 0, 0);
}
if (color) {
accumulator.colors.push(color.rgb[0], color.rgb[1], color.rgb[2]);
}
accumulator.indices.push(vertexOffset + outputIndex);
}
const vertexCount = Math.floor(partVertices.length / 3);
const triangleCount = Math.floor(vertexCount / 3);
if (vertexCount <= 0 || triangleCount <= 0) {
return;
}
const label = String(mesh?.name || mesh?.parent?.name || `3mf:${accumulator.parts.length}`).trim();
const id = `3mf:${accumulator.parts.length}`;
accumulator.parts.push({
id,
occurrenceId: id,
name: label || id,
label: label || id,
nodeType: "part",
color: color && !color.generatedDefault ? color.hex : "",
bounds: buildBoundsFromVertices(partVertices),
vertexOffset,
vertexCount,
triangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0,
});
if (color?.hex && !color.generatedDefault) {
accumulator.colorSet.add(color.hex.toLowerCase());
}
}
export function buildMeshDataFromThreeMfGroup(THREE, group) {
const accumulator = {
vertices: [],
indices: [],
normals: [],
colors: [],
parts: [],
colorSet: new Set(),
};
group?.updateWorldMatrix?.(true, true);
group?.traverse?.((object) => {
if (object?.isMesh && object.geometry) {
const groups = Array.isArray(object.geometry.groups) && object.geometry.groups.length
? object.geometry.groups
: [null];
for (const geometryGroup of groups) {
appendMeshPrimitive(THREE, accumulator, object, geometryGroup, materialForGroup(object.material, geometryGroup));
}
}
});
const vertices = new Float32Array(accumulator.vertices);
const rawColors = accumulator.colors.length === accumulator.vertices.length
? new Float32Array(accumulator.colors)
: new Float32Array(0);
const hasSourceColors = rawColors.length === vertices.length && rawColors.length > 0 && accumulator.colorSet.size > 0;
return {
vertices,
indices: new Uint32Array(accumulator.indices),
normals: new Float32Array(accumulator.normals),
colors: hasSourceColors ? rawColors : new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: buildBoundsFromVertices(vertices),
parts: accumulator.parts,
has_source_colors: hasSourceColors,
sourceColor: accumulator.colorSet.size === 1 ? [...accumulator.colorSet][0] : "",
};
}
function appendParsedMeshPart(THREE, accumulator, meshData, matrix, label) {
if (!meshData?.vertices?.length || !meshData?.triangles?.length) {
return;
}
const vertexOffset = Math.floor(accumulator.vertices.length / 3);
const triangleOffset = Math.floor(accumulator.indices.length / 3);
const positionVector = new THREE.Vector3();
const transformedTriangle = [
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
];
const edgeA = new THREE.Vector3();
const edgeB = new THREE.Vector3();
const normal = new THREE.Vector3();
const partVertices = [];
for (const triangle of meshData.triangles) {
const sourceVertices = triangle.map((index) => meshData.vertices[index]);
if (sourceVertices.some((vertex) => !vertex)) {
continue;
}
for (let index = 0; index < 3; index += 1) {
const vertex = sourceVertices[index];
transformedTriangle[index].set(vertex[0], vertex[1], vertex[2]).applyMatrix4(matrix);
}
edgeA.subVectors(transformedTriangle[1], transformedTriangle[0]);
edgeB.subVectors(transformedTriangle[2], transformedTriangle[0]);
normal.crossVectors(edgeA, edgeB).normalize();
if (!Number.isFinite(normal.x) || !Number.isFinite(normal.y) || !Number.isFinite(normal.z)) {
normal.set(0, 0, 0);
}
for (const transformedVertex of transformedTriangle) {
positionVector.copy(transformedVertex);
const outputIndex = Math.floor(partVertices.length / 3);
accumulator.vertices.push(positionVector.x, positionVector.y, positionVector.z);
partVertices.push(positionVector.x, positionVector.y, positionVector.z);
accumulator.normals.push(normal.x, normal.y, normal.z);
accumulator.indices.push(vertexOffset + outputIndex);
}
}
const vertexCount = Math.floor(partVertices.length / 3);
const triangleCount = Math.floor(vertexCount / 3);
if (vertexCount <= 0 || triangleCount <= 0) {
return;
}
const id = `3mf:${accumulator.parts.length}`;
const partLabel = String(label || id).trim() || id;
accumulator.parts.push({
id,
occurrenceId: id,
name: partLabel,
label: partLabel,
nodeType: "part",
color: "",
bounds: buildBoundsFromVertices(partVertices),
vertexOffset,
vertexCount,
triangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0,
});
}
function findObjectInAnyModel(models, objectId) {
for (const [modelPath, model] of models) {
if (model.objects.has(objectId)) {
return { modelPath, objectData: model.objects.get(objectId) };
}
}
return null;
}
function appendParsed3MfObject(THREE, accumulator, models, modelPath, objectId, matrix, stack = []) {
const stackKey = `${modelPath}#${objectId}`;
if (stack.includes(stackKey)) {
throw new Error(`3MF component cycle detected at ${stackKey}`);
}
const model = models.get(modelPath);
let objectData = model?.objects?.get(objectId) || null;
let resolvedModelPath = modelPath;
if (!objectData) {
const fallback = findObjectInAnyModel(models, objectId);
objectData = fallback?.objectData || null;
resolvedModelPath = fallback?.modelPath || modelPath;
}
if (!objectData) {
throw new Error(`3MF object ${objectId} is missing from ${modelPath}`);
}
if (objectData.mesh) {
appendParsedMeshPart(
THREE,
accumulator,
objectData.mesh,
matrix,
objectData.name || `${resolvedModelPath}#${objectData.id}`
);
return;
}
for (const component of objectData.components || []) {
const componentPath = component.path || resolvedModelPath;
const componentMatrix = matrix.clone();
if (component.transform) {
componentMatrix.multiply(component.transform);
}
appendParsed3MfObject(
THREE,
accumulator,
models,
componentPath,
component.objectId,
componentMatrix,
[...stack, stackKey]
);
}
}
function finalizeParsed3MfMesh(accumulator) {
const vertices = new Float32Array(accumulator.vertices);
return {
vertices,
indices: new Uint32Array(accumulator.indices),
normals: new Float32Array(accumulator.normals),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: buildBoundsFromVertices(vertices),
parts: accumulator.parts,
has_source_colors: false,
sourceColor: "",
};
}
async function buildMeshDataFrom3MfPackageFallback(THREE, buffer) {
const fflate = await import("three/examples/jsm/libs/fflate.module.js");
const textDecoder = new TextDecoder();
const zipEntries = fflate.unzipSync(new Uint8Array(buffer));
const textEntry = (entryPath) => {
const normalizedPath = normalize3MfPackagePath(entryPath);
const payload = zipEntries[normalizedPath];
return payload ? textDecoder.decode(payload) : "";
};
const modelPaths = Object.keys(zipEntries)
.filter((entryPath) => /^3D\/.*\.model$/i.test(entryPath))
.sort((left, right) => {
const leftRoot = /\/3dmodel\.model$/i.test(left) ? 1 : 0;
const rightRoot = /\/3dmodel\.model$/i.test(right) ? 1 : 0;
return leftRoot - rightRoot || left.localeCompare(right);
});
if (!modelPaths.length) {
throw new Error("3MF package does not contain any 3D model parts");
}
const models = new Map();
for (const modelPath of modelPaths) {
const xmlText = textEntry(modelPath);
if (xmlText) {
models.set(modelPath, parse3MfModelPart(THREE, xmlText, modelPath));
}
}
const relationshipsText = textEntry("_rels/.rels");
const rootModelPath = relationshipsText
? parse3MfRelationships(relationshipsText, "_rels/.rels")
.find((relationship) => /\.model$/i.test(relationship.target))?.target
: "";
const buildModelPath = models.has(rootModelPath) ? rootModelPath : modelPaths.find((modelPath) => models.get(modelPath)?.build?.length) || modelPaths[0];
const buildModel = models.get(buildModelPath);
if (!buildModel) {
throw new Error("3MF package does not contain a readable build model");
}
const identity = new THREE.Matrix4();
const accumulator = {
vertices: [],
indices: [],
normals: [],
parts: [],
};
const buildItems = buildModel.build.length
? buildModel.build
: Array.from(buildModel.objects.keys()).map((objectId) => ({ objectId, transform: null }));
for (const buildItem of buildItems) {
const matrix = buildItem.transform ? buildItem.transform.clone() : identity.clone();
appendParsed3MfObject(THREE, accumulator, models, buildModelPath, buildItem.objectId, matrix);
}
const meshData = finalizeParsed3MfMesh(accumulator);
if (!meshData.indices.length) {
throw new Error("3MF package did not produce any triangles");
}
return meshData;
}
export async function buildMeshDataFrom3MfBuffer(buffer) {
const [THREE, { ThreeMFLoader }] = await Promise.all([
import("three"),
import("three/examples/jsm/loaders/3MFLoader.js"),
]);
const loader = new ThreeMFLoader();
try {
return buildMeshDataFromThreeMfGroup(THREE, loader.parse(buffer));
} catch (error) {
try {
return await buildMeshDataFrom3MfPackageFallback(THREE, buffer);
} catch {
throw error;
}
}
}
@@ -0,0 +1,618 @@
import { parseDxf } from "./dxf/parseDxf.js";
import { parseGcode } from "./gcode/parseGcode.js";
import {
STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
STEP_EDGE_CLASS_ATTRIBUTE,
STEP_TOPOLOGY_EXTENSION,
STEP_TOPOLOGY_SCHEMA_VERSION,
isCurrentStepTopologySchemaVersion
} from "../common/stepTopology.mjs";
import { buildMeshDataFromGlbBuffer } from "./render/glbMeshData.js";
import { buildMeshDataFromStlBuffer } from "./render/stlMeshData.js";
import { buildMeshDataFrom3MfBuffer } from "./render/threeMfMeshData.js";
import { loadGlbMeshDataInWorker } from "./render/glbMeshWorkerClient.js";
import { loadStlMeshDataInWorker } from "./render/stlMeshWorkerClient.js";
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function fetchError(url, response) {
return new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
const jsonCache = new Map();
const textCache = new Map();
const arrayBufferCache = new Map();
const arrayBufferPendingCache = new Map();
const glbCache = new Map();
const stlCache = new Map();
const threeMfCache = new Map();
const selectorCache = new Map();
const displayEdgeCache = new Map();
const topologyIndexCache = new Map();
const dxfCache = new Map();
const gcodeCache = new Map();
const urdfCache = new Map();
const srdfCache = new Map();
const sdfCache = new Map();
const GIT_LFS_POINTER_PREFIX = "version https://git-lfs.github.com/spec/v1";
const GIT_LFS_POINTER_SCAN_BYTES = 512;
async function fetchJson(url, { signal } = {}) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.json();
}
async function fetchText(url, { signal } = {}) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.text();
}
async function fetchArrayBuffer(url, { signal } = {}) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.arrayBuffer();
}
async function loadCached(cache, key, loader, { cachePending = true } = {}) {
if (!key) {
throw new Error("Missing asset cache key");
}
if (cache.has(key)) {
const cached = cache.get(key);
if (cachePending || typeof cached?.then !== "function") {
return cached;
}
}
if (!cachePending) {
const payload = await loader();
cache.set(key, payload);
return payload;
}
let pending;
pending = loader().catch((error) => {
if (cache.get(key) === pending) {
cache.delete(key);
}
throw error;
});
cache.set(key, pending);
return pending;
}
function peekCached(cache, key) {
const value = cache.get(key);
return value && typeof value.then !== "function" ? value : null;
}
function finalizeCached(cache, key, value) {
cache.set(key, value);
return value;
}
export function isAbortError(error) {
return error instanceof DOMException && error.name === "AbortError";
}
export function gitLfsPointerDetailsFromBuffer(buffer) {
const byteLength = Number(buffer?.byteLength || 0);
if (!Number.isFinite(byteLength) || byteLength <= 0) {
return null;
}
const preview = new TextDecoder("utf-8").decode(
new Uint8Array(buffer, 0, Math.min(byteLength, GIT_LFS_POINTER_SCAN_BYTES))
);
if (!preview.startsWith(GIT_LFS_POINTER_PREFIX)) {
return null;
}
const oid = preview.match(/^oid sha256:([0-9a-f]{64})\r?$/m)?.[1] || "";
const sizeText = preview.match(/^size ([0-9]+)\r?$/m)?.[1] || "";
const size = sizeText ? Number(sizeText) : null;
return {
oid,
size: Number.isSafeInteger(size) ? size : null,
};
}
function displayPathFromUrl(url) {
const rawUrl = String(url || "").trim();
if (!rawUrl) {
return "(unknown)";
}
try {
const baseUrl = typeof window !== "undefined" && window.location?.href
? window.location.href
: "http://localhost/";
const parsed = new URL(rawUrl, baseUrl);
return decodeURIComponent(parsed.pathname || rawUrl);
} catch {
return rawUrl.split(/[?#]/)[0] || rawUrl;
}
}
export function assertNotGitLfsPointer(buffer, url, assetLabel = "Render asset") {
const pointer = gitLfsPointerDetailsFromBuffer(buffer);
if (!pointer) {
return;
}
const sizeText = pointer.size !== null ? ` Expected LFS object size: ${pointer.size} bytes.` : "";
const oidText = pointer.oid ? ` sha256:${pointer.oid}.` : "";
throw new Error(
`${assetLabel} is a Git LFS pointer, not downloaded mesh data: ${displayPathFromUrl(url)}.${oidText}${sizeText} Fetch the LFS object for this file and reload the viewer.`
);
}
function makeAbortError() {
if (typeof DOMException === "function") {
return new DOMException("The operation was aborted.", "AbortError");
}
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
function withConsumerAbort(promise, signal) {
if (!signal) {
return promise;
}
if (signal.aborted) {
return Promise.reject(makeAbortError());
}
return new Promise((resolve, reject) => {
const cleanup = () => {
signal.removeEventListener?.("abort", abort);
};
const abort = () => {
cleanup();
reject(makeAbortError());
};
signal.addEventListener?.("abort", abort, { once: true });
promise.then(
(value) => {
cleanup();
resolve(value);
},
(error) => {
cleanup();
reject(error);
}
);
});
}
export async function loadRenderJson(url, { signal } = {}) {
const payload = await loadCached(jsonCache, url, () => fetchJson(url, { signal }), { cachePending: !signal });
return finalizeCached(jsonCache, url, payload);
}
export function peekRenderJson(url) {
return peekCached(jsonCache, url);
}
export async function loadRenderText(url, { signal } = {}) {
const payload = await loadCached(textCache, url, () => fetchText(url, { signal }), { cachePending: !signal });
return finalizeCached(textCache, url, payload);
}
export function peekRenderText(url) {
return peekCached(textCache, url);
}
export async function loadRenderArrayBuffer(url, { signal } = {}) {
if (!url) {
throw new Error("Missing asset cache key");
}
if (signal?.aborted) {
throw makeAbortError();
}
const cached = peekCached(arrayBufferCache, url);
if (cached) {
return cached;
}
let pending = arrayBufferPendingCache.get(url);
if (!pending) {
pending = fetchArrayBuffer(url)
.then((payload) => finalizeCached(arrayBufferCache, url, payload))
.finally(() => {
if (arrayBufferPendingCache.get(url) === pending) {
arrayBufferPendingCache.delete(url);
}
});
arrayBufferPendingCache.set(url, pending);
}
return withConsumerAbort(pending, signal);
}
export function peekRenderArrayBuffer(url) {
return peekCached(arrayBufferCache, url);
}
export async function loadRenderGlb(url, { signal, preferWorker = false } = {}) {
const meshData = await loadCached(glbCache, url, async () => {
if (preferWorker) {
const workerMeshData = loadGlbMeshDataInWorker(url, { signal });
if (workerMeshData) {
try {
return await workerMeshData;
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
throw error;
}
}
}
}
const buffer = await loadRenderArrayBuffer(url, { signal });
assertNotGitLfsPointer(buffer, url, "GLB render asset");
return buildMeshDataFromGlbBuffer(buffer);
}, { cachePending: !signal });
return finalizeCached(glbCache, url, meshData);
}
export function peekRenderGlb(url) {
return peekCached(glbCache, url);
}
export async function loadRenderStl(url, { signal } = {}) {
const meshData = await loadCached(stlCache, url, async () => {
const workerMeshData = loadStlMeshDataInWorker(url, { signal });
if (workerMeshData) {
try {
return await workerMeshData;
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
throw error;
}
}
}
const buffer = await loadRenderArrayBuffer(url, { signal });
assertNotGitLfsPointer(buffer, url, "STL render asset");
return buildMeshDataFromStlBuffer(buffer);
}, { cachePending: !signal });
return finalizeCached(stlCache, url, meshData);
}
export function peekRenderStl(url) {
return peekCached(stlCache, url);
}
export async function loadRender3Mf(url, { signal } = {}) {
const meshData = await loadCached(threeMfCache, url, async () => {
const buffer = await loadRenderArrayBuffer(url, { signal });
assertNotGitLfsPointer(buffer, url, "3MF render asset");
return buildMeshDataFrom3MfBuffer(buffer);
}, { cachePending: !signal });
return finalizeCached(threeMfCache, url, meshData);
}
export function peekRender3Mf(url) {
return peekCached(threeMfCache, url);
}
function parseGlbContainer(arrayBuffer) {
const data = new DataView(arrayBuffer);
if (data.byteLength < 20 || data.getUint32(0, true) !== 0x46546c67 || data.getUint32(4, true) !== 2) {
throw new Error("Invalid GLB topology container");
}
const totalLength = Math.min(data.getUint32(8, true), data.byteLength);
let offset = 12;
let json = null;
let bin = null;
while (offset + 8 <= totalLength) {
const chunkLength = data.getUint32(offset, true);
const chunkType = data.getUint32(offset + 4, true);
offset += 8;
if (offset + chunkLength > totalLength) {
throw new Error("Invalid GLB chunk length");
}
if (chunkType === 0x4e4f534a) {
json = JSON.parse(new TextDecoder("utf-8").decode(arrayBuffer.slice(offset, offset + chunkLength)).trim());
} else if (chunkType === 0x004e4942) {
bin = {
buffer: arrayBuffer,
byteOffset: offset,
byteLength: chunkLength,
};
}
offset += chunkLength;
}
if (!json || !bin) {
throw new Error("GLB topology requires JSON and BIN chunks");
}
return { json, bin };
}
function glbBufferViewRange(gltf, bin, viewIndex) {
const index = Number(viewIndex);
const view = Array.isArray(gltf?.bufferViews) ? gltf.bufferViews[index] : null;
if (!Number.isInteger(index) || !view || Number(view.buffer || 0) !== 0) {
return null;
}
const byteOffset = bin.byteOffset + Number(view.byteOffset || 0);
const byteLength = Number(view.byteLength || 0);
if (!Number.isFinite(byteOffset) || !Number.isFinite(byteLength) || byteLength < 0) {
return null;
}
if (byteOffset < bin.byteOffset || byteOffset + byteLength > bin.byteOffset + bin.byteLength) {
return null;
}
return { byteOffset, byteLength };
}
function buildTypedView(glb, view) {
if (!isObject(view)) {
return null;
}
const range = glbBufferViewRange(glb.json, glb.bin, view.bufferView);
if (!range) {
return null;
}
const count = Number(view.count || 0);
const relativeOffset = Number(view.byteOffset || 0);
if (!Number.isFinite(count) || count < 0 || !Number.isFinite(relativeOffset) || relativeOffset < 0) {
return null;
}
const byteOffset = range.byteOffset + relativeOffset;
if (view.dtype === "float32") {
return new Float32Array(glb.bin.buffer, byteOffset, count);
}
if (view.dtype === "uint32") {
return new Uint32Array(glb.bin.buffer, byteOffset, count);
}
return null;
}
function buildSelectorBuffers(manifest, glb) {
const views = manifest?.buffers?.views;
if (!isObject(views)) {
return {};
}
const output = {};
for (const [name, view] of Object.entries(views)) {
const typed = buildTypedView(glb, view);
if (typed) {
output[name] = typed;
}
}
return output;
}
function glbPrimitivesHaveSurfaceEdgeAttributes(glb) {
const meshes = Array.isArray(glb?.json?.meshes) ? glb.json.meshes : [];
let primitiveCount = 0;
for (const mesh of meshes) {
for (const primitive of Array.isArray(mesh?.primitives) ? mesh.primitives : []) {
primitiveCount += 1;
const attributes = primitive?.attributes || {};
if (
attributes[STEP_EDGE_BARYCENTRIC_ATTRIBUTE] === undefined ||
attributes[STEP_EDGE_CLASS_ATTRIBUTE] === undefined
) {
return false;
}
}
}
return primitiveCount > 0;
}
function stepTopologyExtension(glb) {
const extension = glb.json?.extensions?.[STEP_TOPOLOGY_EXTENSION];
if (!isObject(extension)) {
throw new Error(`GLB is missing ${STEP_TOPOLOGY_EXTENSION}`);
}
if (!isCurrentStepTopologySchemaVersion(extension.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} schemaVersion ${extension.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
if (extension.edgeView === undefined || extension.edgeView === null) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} edgeView is not available`);
}
if (!glbPrimitivesHaveSurfaceEdgeAttributes(glb)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} requires ${STEP_EDGE_BARYCENTRIC_ATTRIBUTE} and ${STEP_EDGE_CLASS_ATTRIBUTE} on every STEP mesh primitive`);
}
return extension;
}
function parseJsonBufferView(glb, viewIndex, encoding = "utf-8") {
const range = glbBufferViewRange(glb.json, glb.bin, viewIndex);
if (!range) {
return null;
}
const bytes = new Uint8Array(glb.bin.buffer, range.byteOffset, range.byteLength);
return JSON.parse(new TextDecoder(String(encoding || "utf-8")).decode(bytes));
}
function topologyIndexFromGlbBuffer(arrayBuffer) {
const glb = parseGlbContainer(arrayBuffer);
const extension = stepTopologyExtension(glb);
const manifest = parseJsonBufferView(glb, extension.indexView, extension.encoding);
if (!isObject(manifest)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} indexView is invalid`);
}
if (!isCurrentStepTopologySchemaVersion(manifest.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} index schemaVersion ${manifest.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
return manifest;
}
function selectorBundleFromGlbBuffer(arrayBuffer) {
const glb = parseGlbContainer(arrayBuffer);
const extension = stepTopologyExtension(glb);
const manifest = parseJsonBufferView(glb, extension.selectorView, extension.encoding);
if (!isObject(manifest)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} selectorView is not available`);
}
if (!isCurrentStepTopologySchemaVersion(manifest.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} selector schemaVersion ${manifest.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
if (manifest?.buffers?.littleEndian === false) {
throw new Error("Big-endian selector buffers are not supported");
}
return {
manifest,
buffers: buildSelectorBuffers(manifest, glb),
};
}
function displayEdgeBundleFromGlbBuffer(arrayBuffer) {
const glb = parseGlbContainer(arrayBuffer);
const extension = stepTopologyExtension(glb);
const manifest = parseJsonBufferView(glb, extension.edgeView, extension.encoding);
if (!isObject(manifest)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} edgeView is not available`);
}
if (!isCurrentStepTopologySchemaVersion(manifest.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} edgeView schemaVersion ${manifest.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
if (String(manifest.profile || "") !== "surface-edges") {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} edgeView has unsupported profile ${manifest.profile || "unknown"}`);
}
if (manifest?.buffers?.littleEndian === false) {
throw new Error("Big-endian edgeView buffers are not supported");
}
return {
manifest,
buffers: buildSelectorBuffers(manifest, glb),
};
}
export async function loadRenderTopologyIndex(glbUrl, { signal } = {}) {
const manifest = await loadCached(topologyIndexCache, glbUrl, async () => {
const arrayBuffer = await loadRenderArrayBuffer(glbUrl, { signal });
assertNotGitLfsPointer(arrayBuffer, glbUrl, "GLB topology asset");
return topologyIndexFromGlbBuffer(arrayBuffer);
}, { cachePending: !signal });
return finalizeCached(topologyIndexCache, glbUrl, manifest);
}
export function peekRenderTopologyIndex(glbUrl) {
return peekCached(topologyIndexCache, glbUrl);
}
export async function loadRenderSelectorBundle(glbUrl, { signal } = {}) {
const cacheKey = glbUrl;
const bundle = await loadCached(selectorCache, cacheKey, async () => {
const arrayBuffer = await loadRenderArrayBuffer(glbUrl, { signal });
assertNotGitLfsPointer(arrayBuffer, glbUrl, "GLB selector topology asset");
return selectorBundleFromGlbBuffer(arrayBuffer);
}, { cachePending: !signal });
return finalizeCached(selectorCache, cacheKey, bundle);
}
export function peekRenderSelectorBundle(glbUrl) {
return peekCached(selectorCache, glbUrl);
}
export async function loadRenderDisplayEdgeBundle(glbUrl, { signal } = {}) {
const cacheKey = glbUrl;
const bundle = await loadCached(displayEdgeCache, cacheKey, async () => {
const arrayBuffer = await loadRenderArrayBuffer(glbUrl, { signal });
assertNotGitLfsPointer(arrayBuffer, glbUrl, "GLB display edge topology asset");
return displayEdgeBundleFromGlbBuffer(arrayBuffer);
}, { cachePending: !signal });
return finalizeCached(displayEdgeCache, cacheKey, bundle);
}
export function peekRenderDisplayEdgeBundle(glbUrl) {
return peekCached(displayEdgeCache, glbUrl);
}
export async function loadRenderDxf(url, { signal } = {}) {
const payload = await loadCached(dxfCache, url, async () => {
const dxfText = await loadRenderText(url, { signal });
return parseDxf(dxfText, { fileRef: "", sourceUrl: url });
}, { cachePending: !signal });
return finalizeCached(dxfCache, url, payload);
}
export function peekRenderDxf(url) {
return peekCached(dxfCache, url);
}
export async function loadRenderGcode(url, { signal } = {}) {
const payload = await loadCached(gcodeCache, url, async () => {
const gcodeText = await loadRenderText(url, { signal });
return parseGcode(gcodeText, { fileRef: "", sourceUrl: url });
}, { cachePending: !signal });
return finalizeCached(gcodeCache, url, payload);
}
export function peekRenderGcode(url) {
return peekCached(gcodeCache, url);
}
function urdfCacheKey(url) {
return String(url || "");
}
export async function loadRenderUrdf(url, { signal } = {}) {
const cacheKey = urdfCacheKey(url);
const payload = await loadCached(urdfCache, cacheKey, async () => {
const [xmlText, { parseUrdf }] = await Promise.all([
loadRenderText(url, { signal }),
import("./urdf/parseUrdf.js"),
]);
return parseUrdf(xmlText, { sourceUrl: url });
}, { cachePending: !signal });
return finalizeCached(urdfCache, cacheKey, payload);
}
export function peekRenderUrdf(url) {
return peekCached(urdfCache, urdfCacheKey(url));
}
function srdfCacheKey(srdfUrl, urdfUrl = "") {
return [srdfUrl, urdfUrl].filter(Boolean).join("::");
}
export async function loadRenderSrdf(srdfUrl, { signal, urdfUrl = "" } = {}) {
const cacheKey = srdfCacheKey(srdfUrl, urdfUrl);
const payload = await loadCached(srdfCache, cacheKey, async () => {
const [srdfText, urdfData, { parseSrdf, motionFromSrdf }] = await Promise.all([
loadRenderText(srdfUrl, { signal }),
loadRenderUrdf(urdfUrl, { signal }),
import("./urdf/parseSrdf.js"),
]);
const srdfData = parseSrdf(srdfText, { sourceUrl: srdfUrl, urdfData });
return {
srdfData,
urdfData: {
...urdfData,
motion: motionFromSrdf(srdfData),
srdf: srdfData
}
};
}, { cachePending: !signal });
return finalizeCached(srdfCache, cacheKey, payload);
}
export function peekRenderSrdf(srdfUrl, { urdfUrl = "" } = {}) {
return peekCached(srdfCache, srdfCacheKey(srdfUrl, urdfUrl));
}
function sdfCacheKey(url) {
return String(url || "");
}
export async function loadRenderSdf(url, { signal } = {}) {
const cacheKey = sdfCacheKey(url);
const payload = await loadCached(sdfCache, cacheKey, async () => {
const [xmlText, { parseSdf }] = await Promise.all([
loadRenderText(url, { signal }),
import("./urdf/parseSdf.js"),
]);
return parseSdf(xmlText, { sourceUrl: url });
}, { cachePending: !signal });
return finalizeCached(sdfCache, cacheKey, payload);
}
export function peekRenderSdf(url) {
return peekCached(sdfCache, sdfCacheKey(url));
}
@@ -0,0 +1,893 @@
import { buildCadRefToken } from "../cadRefs.js";
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toRows(manifest, rowKey, columnsKey) {
const columns = manifest?.tables?.[columnsKey];
const rows = manifest?.[rowKey];
if (!Array.isArray(columns) || !Array.isArray(rows)) {
return [];
}
return rows
.filter(Array.isArray)
.map((row) => Object.fromEntries(columns.map((column, index) => [column, row[index]])));
}
function relationArray(manifest, buffers, relationKey, viewKey) {
const direct = manifest?.relations?.[relationKey];
if (Array.isArray(direct) || ArrayBuffer.isView(direct)) {
return direct;
}
const viewName = manifest?.relations?.[viewKey];
if (typeof viewName === "string" && buffers?.[viewName]) {
return buffers[viewName];
}
return [];
}
function typedBufferView(manifest, buffers, manifestSectionKey, viewKey) {
const viewName = manifest?.[manifestSectionKey]?.[viewKey];
if (typeof viewName === "string" && buffers?.[viewName]) {
return buffers[viewName];
}
return new Uint32Array(0);
}
function selectorPrefix(singleOccurrenceId, selector) {
if (!singleOccurrenceId || !selector.startsWith(`${singleOccurrenceId}.`)) {
return selector;
}
const suffix = selector.slice(singleOccurrenceId.length + 1);
return suffix.startsWith("s") || suffix.startsWith("f") || suffix.startsWith("e") ? suffix : selector;
}
function selectorTypeLabel(selectorType) {
if (selectorType === "occurrence") {
return "Occurrence";
}
if (selectorType === "shape") {
return "Shape";
}
if (selectorType === "face") {
return "Face";
}
return "Edge";
}
function transformPoint(transform, point) {
if (!Array.isArray(point) || point.length < 3) {
return point;
}
if (!Array.isArray(transform) || transform.length < 16) {
return [Number(point[0]), Number(point[1]), Number(point[2])];
}
const x = Number(point[0]);
const y = Number(point[1]);
const z = Number(point[2]);
return [
(transform[0] * x) + (transform[1] * y) + (transform[2] * z) + transform[3],
(transform[4] * x) + (transform[5] * y) + (transform[6] * z) + transform[7],
(transform[8] * x) + (transform[9] * y) + (transform[10] * z) + transform[11],
];
}
function normalizeVector(vector) {
const x = Number(vector?.[0] || 0);
const y = Number(vector?.[1] || 0);
const z = Number(vector?.[2] || 0);
const magnitude = Math.hypot(x, y, z);
if (magnitude <= 1e-9) {
return null;
}
return [x / magnitude, y / magnitude, z / magnitude];
}
function transformVector(transform, vector) {
if (!Array.isArray(vector) || vector.length < 3 || !Array.isArray(transform) || transform.length < 16) {
return normalizeVector(vector || []);
}
return normalizeVector([
(transform[0] * vector[0]) + (transform[1] * vector[1]) + (transform[2] * vector[2]),
(transform[4] * vector[0]) + (transform[5] * vector[1]) + (transform[6] * vector[2]),
(transform[8] * vector[0]) + (transform[9] * vector[1]) + (transform[10] * vector[2]),
]);
}
function transformBBox(transform, bbox) {
if (!isObject(bbox)) {
return bbox;
}
const min = Array.isArray(bbox.min) ? bbox.min : [0, 0, 0];
const max = Array.isArray(bbox.max) ? bbox.max : [0, 0, 0];
const corners = [
[min[0], min[1], min[2]],
[min[0], min[1], max[2]],
[min[0], max[1], min[2]],
[min[0], max[1], max[2]],
[max[0], min[1], min[2]],
[max[0], min[1], max[2]],
[max[0], max[1], min[2]],
[max[0], max[1], max[2]],
].map((point) => transformPoint(transform, point));
const xs = corners.map((point) => point[0]);
const ys = corners.map((point) => point[1]);
const zs = corners.map((point) => point[2]);
return {
min: [Math.min(...xs), Math.min(...ys), Math.min(...zs)],
max: [Math.max(...xs), Math.max(...ys), Math.max(...zs)],
};
}
function transformParams(transform, params) {
if (!isObject(params)) {
return params;
}
const pointKeys = new Set(["origin", "center", "location"]);
const vectorKeys = new Set(["axis", "direction", "normal"]);
return Object.fromEntries(Object.entries(params).map(([key, value]) => {
if (pointKeys.has(key) && Array.isArray(value) && value.length === 3) {
return [key, transformPoint(transform, value)];
}
if (vectorKeys.has(key) && Array.isArray(value) && value.length === 3) {
return [key, transformVector(transform, value)];
}
return [key, value];
}));
}
function transformPointList(transform, points) {
if (!Array.isArray(points)) {
return points;
}
return points.map((point) => (
Array.isArray(point) && point.length >= 3 ? transformPoint(transform, point) : point
));
}
function transformLoopMetadata(transform, loopsMeta) {
if (!Array.isArray(loopsMeta)) {
return loopsMeta;
}
return loopsMeta.map((loop) => (
isObject(loop) && Array.isArray(loop.points)
? { ...loop, points: transformPointList(transform, loop.points) }
: loop
));
}
function transformSurface(transform, surface) {
if (!isObject(surface)) {
return surface;
}
const pointKeys = new Set(["origin", "center", "location"]);
const vectorKeys = new Set(["axis", "direction", "normal", "xDir", "yDir"]);
return Object.fromEntries(Object.entries(surface).map(([key, value]) => {
if (pointKeys.has(key) && Array.isArray(value) && value.length === 3) {
return [key, transformPoint(transform, value)];
}
if (vectorKeys.has(key) && Array.isArray(value) && value.length === 3) {
return [key, transformVector(transform, value)];
}
return [key, value];
}));
}
function transformPositions(values, transform) {
if (!(values instanceof Float32Array) || !Array.isArray(transform) || transform.length < 16) {
return values;
}
const next = new Float32Array(values.length);
for (let index = 0; index < values.length; index += 3) {
const point = transformPoint(transform, [values[index], values[index + 1], values[index + 2]]);
next[index] = point[0];
next[index + 1] = point[1];
next[index + 2] = point[2];
}
return next;
}
function transformPickData(pickData, transform) {
if (!isObject(pickData) || !Array.isArray(transform) || transform.length < 16) {
return pickData;
}
return {
...pickData,
bbox: pickData.bbox ? transformBBox(transform, pickData.bbox) : pickData.bbox,
center: Array.isArray(pickData.center) ? transformPoint(transform, pickData.center) : pickData.center,
normal: Array.isArray(pickData.normal) ? transformVector(transform, pickData.normal) : pickData.normal,
params: pickData.params ? transformParams(transform, pickData.params) : pickData.params,
points: transformPointList(transform, pickData.points),
loops: Array.isArray(pickData.loops)
? pickData.loops.map((loop) => transformPointList(transform, loop))
: pickData.loops,
loopsMeta: transformLoopMetadata(transform, pickData.loopsMeta),
surface: transformSurface(transform, pickData.surface),
centroid: Array.isArray(pickData.centroid) ? transformPoint(transform, pickData.centroid) : pickData.centroid,
transform,
};
}
function referenceIdForRow(displaySelector, selectorType, partId) {
if (partId) {
return `topology|${partId}|${selectorType}|${displaySelector}`;
}
return displaySelector;
}
function referenceSummary(selectorType, row) {
if (selectorType === "occurrence") {
return String(row.name || row.sourceName || row.id || "").trim();
}
if (selectorType === "shape") {
const name = String(row.name || row.sourceName || "").trim();
const detail = `${row.kind || "shape"}${row.volume ? ` volume=${row.volume}` : row.area ? ` area=${row.area}` : ""}`;
return name ? `${name} ${detail}` : detail;
}
if (selectorType === "face") {
return `${row.surfaceType || "face"} area=${row.area ?? 0}`;
}
return `${row.curveType || "edge"} length=${row.length ?? 0}`;
}
function sourceOccurrenceMatchesFilter(sourceOccurrenceId, filterOccurrenceId) {
const sourceId = String(sourceOccurrenceId || "").trim();
const filterId = String(filterOccurrenceId || "").trim();
return !filterId || sourceId === filterId || sourceId.startsWith(`${filterId}.`);
}
function remapSourceOccurrenceId(sourceOccurrenceId, remapOccurrencePrefix) {
const sourceId = String(sourceOccurrenceId || "").trim();
if (!sourceId || !remapOccurrencePrefix || typeof remapOccurrencePrefix !== "object") {
return "";
}
if (!sourceOccurrenceMatchesFilter(sourceId, remapOccurrencePrefix.sourceOccurrenceId)) {
return "";
}
const sourceRootId = String(remapOccurrencePrefix.sourceRootOccurrenceId || "").trim();
const targetRootId = String(remapOccurrencePrefix.targetRootOccurrenceId || "").trim();
if (!sourceRootId || !targetRootId) {
return "";
}
if (sourceId === sourceRootId) {
return targetRootId;
}
const sourceRootPrefix = `${sourceRootId}.`;
if (sourceId.startsWith(sourceRootPrefix)) {
return `${targetRootId}.${sourceId.slice(sourceRootPrefix.length)}`;
}
return "";
}
function selectorForRow(selectorType, row, rowIndex, singleOccurrenceId, remapOccurrenceId = "", remapOccurrencePrefix = null) {
if (!row || !Number.isFinite(Number(rowIndex))) {
return "";
}
if (remapOccurrencePrefix && typeof remapOccurrencePrefix === "object") {
const sourceOccurrenceId = selectorType === "occurrence"
? String(row?.id || "").trim()
: String(row?.occurrenceId || "").trim();
const occurrenceId = remapSourceOccurrenceId(sourceOccurrenceId, remapOccurrencePrefix);
if (!occurrenceId) {
return "";
}
if (selectorType === "occurrence") {
return occurrenceId;
}
const selectorKind = selectorType === "shape"
? "s"
: selectorType === "face"
? "f"
: "e";
return `${occurrenceId}.${selectorKind}${rowIndex + 1}`;
}
const occurrenceId = String(remapOccurrenceId || "").trim();
if (occurrenceId) {
if (selectorType === "occurrence") {
return occurrenceId;
}
const selectorKind = selectorType === "shape"
? "s"
: selectorType === "face"
? "f"
: "e";
return `${occurrenceId}.${selectorKind}${rowIndex + 1}`;
}
return selectorPrefix(singleOccurrenceId, String(row?.id || "").trim());
}
function selectorWithRemappedOccurrence(selector, singleOccurrenceId, remapOccurrenceId = "", remapOccurrencePrefix = null) {
const normalizedSelector = String(selector || "").trim();
if (!normalizedSelector) {
return "";
}
const match = normalizedSelector.match(/^(.*)\.([sfe]\d+)$/i);
if (!match) {
return selectorPrefix(singleOccurrenceId, normalizedSelector);
}
const occurrenceId = String(match[1] || "").trim();
const selectorToken = String(match[2] || "").trim();
if (!occurrenceId || !selectorToken) {
return selectorPrefix(singleOccurrenceId, normalizedSelector);
}
if (remapOccurrencePrefix && typeof remapOccurrencePrefix === "object") {
const remappedOccurrenceId = remapSourceOccurrenceId(occurrenceId, remapOccurrencePrefix);
return remappedOccurrenceId ? `${remappedOccurrenceId}.${selectorToken}` : "";
}
const targetOccurrenceId = String(remapOccurrenceId || "").trim();
if (targetOccurrenceId) {
return `${targetOccurrenceId}.${selectorToken}`;
}
return selectorPrefix(singleOccurrenceId, normalizedSelector);
}
function occurrenceIdForReference(selectorType, row, singleOccurrenceId, remapOccurrenceId = "", remapOccurrencePrefix = null) {
if (selectorType === "occurrence") {
return "";
}
const occurrenceId = String(row?.occurrenceId || "").trim();
if (!occurrenceId) {
return "";
}
if (remapOccurrencePrefix && typeof remapOccurrencePrefix === "object") {
return remapSourceOccurrenceId(occurrenceId, remapOccurrencePrefix);
}
return String(remapOccurrenceId || "").trim() || selectorPrefix(singleOccurrenceId, occurrenceId);
}
function buildAdjacencySelectors(row, relationRows, targetRows, singleOccurrenceId, idKey, startKey, countKey, targetSelectorType, remapOccurrenceId, remapOccurrencePrefix) {
const start = Number(row?.[startKey] || 0);
const count = Number(row?.[countKey] || 0);
const selectors = [];
const end = Math.min(relationRows?.length || 0, start + count);
for (let index = start; index < end; index += 1) {
const rowIndex = relationRows[index];
const targetRowIndex = Number(rowIndex);
const targetRow = targetRows[targetRowIndex];
const selector = selectorForRow(
targetSelectorType,
targetRow,
targetRowIndex,
singleOccurrenceId,
remapOccurrenceId,
remapOccurrencePrefix
) || String(targetRow?.[idKey] || "").trim();
if (selector) {
selectors.push(selector);
}
}
return selectors;
}
function buildReference({
selectorType,
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform,
relationRows,
targetRows,
targetKey,
startKey,
countKey,
remapOccurrenceId = "",
remapOccurrencePrefix = null,
targetSelectorType = "",
}) {
const normalizedSelector = selectorForRow(selectorType, row, rowIndex, singleOccurrenceId, remapOccurrenceId, remapOccurrencePrefix);
const displaySelector = normalizedSelector;
const id = referenceIdForRow(displaySelector, selectorType, partId);
const summary = referenceSummary(selectorType, row);
const copyText = buildCadRefToken({ cadPath: copyCadPath, selector: displaySelector });
const adjacentSelectors = relationRows && targetRows
? buildAdjacencySelectors(
row,
relationRows,
targetRows,
singleOccurrenceId,
targetKey,
startKey,
countKey,
targetSelectorType,
remapOccurrenceId,
remapOccurrencePrefix
)
: [];
return {
id,
selectorType,
normalizedSelector,
displaySelector,
label: `${selectorTypeLabel(selectorType)} ${displaySelector}`,
summary,
shortSummary: summary,
copyText,
partId,
occurrenceId: occurrenceIdForReference(selectorType, row, singleOccurrenceId, remapOccurrenceId, remapOccurrencePrefix),
shapeId: selectorWithRemappedOccurrence(row.shapeId, singleOccurrenceId, remapOccurrenceId, remapOccurrencePrefix),
rowIndex,
pickData: {
selectorType,
rowIndex,
name: row.name || null,
sourceName: row.sourceName || null,
kind: row.kind || null,
bbox: row.bbox || null,
surfaceType: row.surfaceType || null,
center: row.center || null,
normal: row.normal || null,
params: row.params || null,
triangleStart: row.triangleStart ?? 0,
triangleCount: row.triangleCount ?? 0,
segmentStart: row.segmentStart ?? 0,
segmentCount: row.segmentCount ?? 0,
adjacentSelectors,
transform: selectorTransform || null,
},
};
}
function buildLeafOccurrenceIds(shapes) {
return [...new Set(
shapes
.map((row) => String(row.occurrenceId || "").trim())
.filter(Boolean)
)].sort();
}
function transformRows(rows, transform) {
if (!Array.isArray(transform) || transform.length < 16) {
return rows;
}
return rows.map((row) => ({
...row,
transform: Array.isArray(row.transform) ? row.transform : transform,
bbox: row.bbox ? transformBBox(transform, row.bbox) : row.bbox,
center: Array.isArray(row.center) ? transformPoint(transform, row.center) : row.center,
normal: Array.isArray(row.normal) ? transformVector(transform, row.normal) : row.normal,
params: row.params ? transformParams(transform, row.params) : row.params,
}));
}
function transformRow(row, transform) {
if (!row || !Array.isArray(transform) || transform.length < 16) {
return row;
}
return {
...row,
bbox: row.bbox ? transformBBox(transform, row.bbox) : row.bbox,
center: Array.isArray(row.center) ? transformPoint(transform, row.center) : row.center,
normal: Array.isArray(row.normal) ? transformVector(transform, row.normal) : row.normal,
params: row.params ? transformParams(transform, row.params) : row.params,
};
}
function applySequentialRelationStarts(rows, relationSpecs) {
const specs = Array.isArray(relationSpecs?.[0]) ? relationSpecs : [relationSpecs];
const nextStarts = specs.map(() => 0);
return rows.map((row) => {
const nextRow = { ...row };
specs.forEach(([startKey, countKey], specIndex) => {
const count = Math.max(0, Number(row?.[countKey] || 0));
nextRow[startKey] = nextStarts[specIndex];
nextRow[countKey] = count;
nextStarts[specIndex] += count;
});
return nextRow;
});
}
export function buildSelectorRuntime(bundle, {
copyCadPath = "",
partId = "",
transform = null,
remapOccurrenceId = "",
remapOccurrencePrefix = null,
} = {}) {
const manifest = bundle?.manifest || {};
const buffers = bundle?.buffers || {};
const faceRelations = relationArray(manifest, buffers, "faceEdgeRows", "faceEdgeRowsView");
const edgeRelations = relationArray(manifest, buffers, "edgeFaceRows", "edgeFaceRowsView");
const occurrences = transformRows(toRows(manifest, "occurrences", "occurrenceColumns"), transform);
const shapes = transformRows(toRows(manifest, "shapes", "shapeColumns"), transform);
const faces = applySequentialRelationStarts(
transformRows(toRows(manifest, "faces", "faceColumns"), transform),
[["edgeStart", "edgeCount"]]
);
const edges = applySequentialRelationStarts(
transformRows(toRows(manifest, "edges", "edgeColumns"), transform),
[["faceStart", "faceCount"]]
);
const leafOccurrenceIds = buildLeafOccurrenceIds(shapes);
const singleOccurrenceId = leafOccurrenceIds.length === 1 ? leafOccurrenceIds[0] : "";
const selectorBuffers = {
facePositions: transformPositions(buffers.facePositions, transform),
faceIndices: buffers.faceIndices || new Uint32Array(0),
faceIds: buffers.faceIds || new Uint32Array(0),
faceRuns: typedBufferView(manifest, buffers, "faceProxy", "runsView"),
faceRunColumns: Array.isArray(manifest?.faceProxy?.runColumns) ? manifest.faceProxy.runColumns : [],
edgePositions: transformPositions(buffers.edgePositions, transform),
edgeIndices: buffers.edgeIndices || new Uint32Array(0),
edgeIds: buffers.edgeIds || new Uint32Array(0),
faceEdgeRows: faceRelations,
edgeFaceRows: edgeRelations,
};
const references = [];
references.push(...occurrences.map((row, rowIndex) => buildReference({
selectorType: "occurrence",
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform: transform,
remapOccurrenceId,
remapOccurrencePrefix,
})));
references.push(...shapes.map((row, rowIndex) => buildReference({
selectorType: "shape",
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform: transform,
remapOccurrenceId,
remapOccurrencePrefix,
})));
references.push(...faces.map((row, rowIndex) => buildReference({
selectorType: "face",
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform: transform,
relationRows: faceRelations,
targetRows: edges,
targetKey: "id",
startKey: "edgeStart",
countKey: "edgeCount",
remapOccurrenceId,
remapOccurrencePrefix,
targetSelectorType: "edge",
})));
references.push(...edges.map((row, rowIndex) => buildReference({
selectorType: "edge",
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform: transform,
relationRows: edgeRelations,
targetRows: faces,
targetKey: "id",
startKey: "faceStart",
countKey: "faceCount",
remapOccurrenceId,
remapOccurrencePrefix,
targetSelectorType: "face",
})));
const visibleReferences = references.filter((reference) => String(reference?.normalizedSelector || "").trim());
const referenceMap = new Map(visibleReferences.map((reference) => [reference.id, reference]));
const referenceByNormalizedSelector = new Map(
visibleReferences.map((reference) => [reference.normalizedSelector, reference])
);
const referenceByDisplaySelector = new Map(
visibleReferences.map((reference) => [reference.displaySelector, reference])
);
const faceReferenceByRowIndex = new Map(
visibleReferences
.filter((reference) => reference.selectorType === "face")
.map((reference) => [reference.rowIndex, reference])
);
const edgeReferenceByRowIndex = new Map(
visibleReferences
.filter((reference) => reference.selectorType === "edge")
.map((reference) => [reference.rowIndex, reference])
);
const occurrenceIdByRowIndex = new Map(
occurrences.map((row, rowIndex) => [
rowIndex,
selectorForRow("occurrence", row, rowIndex, singleOccurrenceId, remapOccurrenceId, remapOccurrencePrefix) || String(row?.id || "").trim()
])
);
return {
schemaVersion: Number(manifest.schemaVersion || 1),
surfaceEdgeRendering: Boolean(manifest?.capabilities?.surfaceEdgeRendering),
capabilities: manifest.capabilities || null,
cadPath: copyCadPath || String(manifest.cadRef || "").trim(),
stepHash: String(manifest.stepHash || ""),
bbox: transform ? transformBBox(transform, manifest.bbox || {}) : manifest.bbox,
occurrences,
shapes,
faces,
edges,
vertices: [],
references: visibleReferences,
referenceMap,
referenceByNormalizedSelector,
referenceByDisplaySelector,
faceReferenceByRowIndex,
edgeReferenceByRowIndex,
vertexReferenceByRowIndex: new Map(),
occurrenceIdByRowIndex,
faceReferenceMap: new Map(visibleReferences.filter((reference) => reference.selectorType === "face").map((reference) => [reference.id, reference])),
edgeReferenceMap: new Map(visibleReferences.filter((reference) => reference.selectorType === "edge").map((reference) => [reference.id, reference])),
vertexReferenceMap: new Map(),
singleOccurrenceId,
proxy: selectorBuffers,
};
}
export function buildDisplayEdgeRuntime(bundle, {
transform = null,
} = {}) {
const manifest = bundle?.manifest || {};
if (String(manifest.profile || "") === "surface-edges") {
const edgeRendering = manifest.edgeRendering && typeof manifest.edgeRendering === "object"
? manifest.edgeRendering
: {};
return {
schemaVersion: Number(manifest.schemaVersion || 1),
surfaceEdgeRendering: true,
edgeRendering: {
visibilityClasses: Array.isArray(edgeRendering.visibilityClasses)
? edgeRendering.visibilityClasses.map((item) => String(item || "").trim()).filter(Boolean)
: [],
generatedVisibilityClasses: Array.isArray(edgeRendering.generatedVisibilityClasses)
? edgeRendering.generatedVisibilityClasses.map((item) => String(item || "").trim()).filter(Boolean)
: [],
visibilityClassCounts: edgeRendering.visibilityClassCounts || {},
generatedVisibilityClassCounts: edgeRendering.generatedVisibilityClassCounts || {},
},
stepHash: String(manifest.stepHash || ""),
bbox: transform ? transformBBox(transform, manifest.bbox || {}) : manifest.bbox,
edges: [],
proxy: {
edgePositions: new Float32Array(0),
edgeIndices: new Uint32Array(0),
edgeIds: new Uint32Array(0),
},
};
}
const buffers = bundle?.buffers || {};
const edgeProxy = manifest?.edgeProxy || {};
const edgePositionView = String(edgeProxy.positionsView || "edgePositions");
const edgeIndexView = String(edgeProxy.indicesView || "edgeIndices");
const edgeIdView = String(edgeProxy.edgeIdsView || "edgeIds");
const edgePositions = buffers[edgePositionView] instanceof Float32Array
? buffers[edgePositionView]
: new Float32Array(0);
const edgeIndices = buffers[edgeIndexView] instanceof Uint32Array
? buffers[edgeIndexView]
: new Uint32Array(0);
const edgeIds = buffers[edgeIdView] instanceof Uint32Array
? buffers[edgeIdView]
: new Uint32Array(0);
return {
schemaVersion: Number(manifest.schemaVersion || 1),
stepHash: String(manifest.stepHash || ""),
bbox: transform ? transformBBox(transform, manifest.bbox || {}) : manifest.bbox,
edges: transformRows(toRows(manifest, "edges", "edgeColumns"), transform),
proxy: {
edgePositions: transformPositions(edgePositions, transform),
edgeIndices,
edgeIds,
},
};
}
function normalizedTransformEntries(value) {
const entries = value instanceof Map
? [...value.entries()]
: isObject(value)
? Object.entries(value)
: [];
return entries
.map(([key, transform]) => [
String(key || "").trim(),
Array.isArray(transform) && transform.length >= 16
? transform.slice(0, 16).map((component) => Number(component))
: null
])
.filter(([key, transform]) => key && transform && transform.every(Number.isFinite));
}
function transformForOccurrenceId(transformEntries, occurrenceId) {
const normalizedOccurrenceId = String(occurrenceId || "").trim();
const modelTransform = transformEntries.find(([key]) => key === "__model__" || key === "*" || key === "__all__")?.[1] || null;
let best = null;
for (const [partId, transform] of transformEntries) {
if (partId === "__model__" || partId === "*" || partId === "__all__") {
continue;
}
if (
normalizedOccurrenceId &&
(
normalizedOccurrenceId === partId ||
normalizedOccurrenceId.startsWith(`${partId}.`)
) &&
(!best || partId.length > best[0].length)
) {
best = [partId, transform];
}
}
return best?.[1] || modelTransform;
}
function rowOccurrenceId(row, selectorType) {
return selectorType === "occurrence"
? String(row?.id || "").trim()
: String(row?.occurrenceId || row?.partId || "").trim();
}
function transformRowsByOccurrence(rows, selectorType, transformEntries) {
const transforms = [];
const nextRows = (Array.isArray(rows) ? rows : []).map((row) => {
const transform = transformForOccurrenceId(transformEntries, rowOccurrenceId(row, selectorType));
transforms.push(transform);
return transform ? transformRow(row, transform) : row;
});
return { rows: nextRows, transforms };
}
function transformReference(reference, transform) {
if (!transform) {
return reference;
}
return {
...reference,
pickData: transformPickData(reference.pickData, transform),
};
}
function transformIndexedProxy({ positions, indices, ids, rowTransforms, elementSize }) {
if (!(positions instanceof Float32Array) || !(indices instanceof Uint32Array) || !indices.length || !(ids instanceof Uint32Array) || !ids.length) {
return { positions, indices, ids };
}
const elementCount = Math.floor(indices.length / elementSize);
const nextPositions = new Float32Array(elementCount * elementSize * 3);
const nextIndices = new Uint32Array(elementCount * elementSize);
const nextIds = new Uint32Array(elementCount);
let writeVertex = 0;
for (let elementIndex = 0; elementIndex < elementCount; elementIndex += 1) {
const rowIndex = Number(ids[elementIndex]);
const transform = Number.isInteger(rowIndex) ? rowTransforms[rowIndex] || null : null;
nextIds[elementIndex] = Number.isInteger(rowIndex) ? rowIndex : 0;
for (let vertex = 0; vertex < elementSize; vertex += 1) {
const sourceVertex = Number(indices[(elementIndex * elementSize) + vertex]);
const sourceOffset = sourceVertex * 3;
const nextOffset = writeVertex * 3;
const point = [
positions[sourceOffset],
positions[sourceOffset + 1],
positions[sourceOffset + 2],
];
const transformedPoint = transform ? transformPoint(transform, point) : point;
nextPositions[nextOffset] = transformedPoint[0];
nextPositions[nextOffset + 1] = transformedPoint[1];
nextPositions[nextOffset + 2] = transformedPoint[2];
nextIndices[(elementIndex * elementSize) + vertex] = writeVertex;
writeVertex += 1;
}
}
return {
positions: nextPositions,
indices: nextIndices,
ids: nextIds,
};
}
export function buildTransformedSelectorRuntime(selectorRuntime, transformByPartId = null) {
const transformEntries = normalizedTransformEntries(transformByPartId);
if (!selectorRuntime || !transformEntries.length) {
return selectorRuntime || null;
}
const occurrencesResult = transformRowsByOccurrence(selectorRuntime.occurrences, "occurrence", transformEntries);
const shapesResult = transformRowsByOccurrence(selectorRuntime.shapes, "shape", transformEntries);
const facesResult = transformRowsByOccurrence(selectorRuntime.faces, "face", transformEntries);
const edgesResult = transformRowsByOccurrence(selectorRuntime.edges, "edge", transformEntries);
const referenceTransforms = {
occurrence: occurrencesResult.transforms,
shape: shapesResult.transforms,
face: facesResult.transforms,
edge: edgesResult.transforms,
vertex: [],
};
const references = (Array.isArray(selectorRuntime.references) ? selectorRuntime.references : [])
.map((reference) => transformReference(
reference,
referenceTransforms[reference?.selectorType]?.[Number(reference?.rowIndex)] || null
));
const referenceMap = new Map(references.map((reference) => [reference.id, reference]));
const referenceByNormalizedSelector = new Map(references.map((reference) => [reference.normalizedSelector, reference]));
const referenceByDisplaySelector = new Map(references.map((reference) => [reference.displaySelector, reference]));
const faceReferenceByRowIndex = new Map(
references
.filter((reference) => reference.selectorType === "face")
.map((reference) => [reference.rowIndex, reference])
);
const edgeReferenceByRowIndex = new Map(
references
.filter((reference) => reference.selectorType === "edge")
.map((reference) => [reference.rowIndex, reference])
);
const proxy = selectorRuntime.proxy || {};
const faceProxy = transformIndexedProxy({
positions: proxy.facePositions,
indices: proxy.faceIndices,
ids: proxy.faceIds,
rowTransforms: facesResult.transforms,
elementSize: 3,
});
const edgeProxy = transformIndexedProxy({
positions: proxy.edgePositions,
indices: proxy.edgeIndices,
ids: proxy.edgeIds,
rowTransforms: edgesResult.transforms,
elementSize: 2,
});
return {
...selectorRuntime,
bbox: selectorRuntime.bbox ? transformBBox(transformEntries.find(([key]) => key === "__model__" || key === "*" || key === "__all__")?.[1], selectorRuntime.bbox) : selectorRuntime.bbox,
occurrences: occurrencesResult.rows,
shapes: shapesResult.rows,
faces: facesResult.rows,
edges: edgesResult.rows,
references,
referenceMap,
referenceByNormalizedSelector,
referenceByDisplaySelector,
faceReferenceByRowIndex,
edgeReferenceByRowIndex,
faceReferenceMap: new Map(references.filter((reference) => reference.selectorType === "face").map((reference) => [reference.id, reference])),
edgeReferenceMap: new Map(references.filter((reference) => reference.selectorType === "edge").map((reference) => [reference.id, reference])),
proxy: {
...proxy,
facePositions: faceProxy.positions,
faceIndices: faceProxy.indices,
faceIds: faceProxy.ids,
edgePositions: edgeProxy.positions,
edgeIndices: edgeProxy.indices,
edgeIds: edgeProxy.ids,
},
};
}
export function buildTransformedDisplayEdgeRuntime(displayEdgeRuntime, transformByPartId = null) {
const transformEntries = normalizedTransformEntries(transformByPartId);
if (!displayEdgeRuntime || !transformEntries.length) {
return displayEdgeRuntime || null;
}
const edgesResult = transformRowsByOccurrence(displayEdgeRuntime.edges, "edge", transformEntries);
const proxy = displayEdgeRuntime.proxy || {};
const edgeProxy = transformIndexedProxy({
positions: proxy.edgePositions,
indices: proxy.edgeIndices,
ids: proxy.edgeIds,
rowTransforms: edgesResult.transforms,
elementSize: 2,
});
const modelTransform = transformEntries.find(([key]) => key === "__model__" || key === "*" || key === "__all__")?.[1] || null;
return {
...displayEdgeRuntime,
bbox: modelTransform && displayEdgeRuntime.bbox ? transformBBox(modelTransform, displayEdgeRuntime.bbox) : displayEdgeRuntime.bbox,
edges: edgesResult.rows,
proxy: {
...proxy,
edgePositions: edgeProxy.positions,
edgeIndices: edgeProxy.indices,
edgeIds: edgeProxy.ids,
},
};
}

Some files were not shown because too many files have changed in this diff Show More