1239 lines
44 KiB
JavaScript
1239 lines
44 KiB
JavaScript
import * as THREE from "../vendor/three/three.module.js";
|
|
import {
|
|
linearUnitsLabel,
|
|
linearUnitsToMetersFactor,
|
|
linearValueToMeters,
|
|
resolveStateLinearUnits,
|
|
} from "../runtime/linear-units.js";
|
|
import { buildVismachModelState } from "../runtime/vismach-model-state.js";
|
|
|
|
const scenes = new WeakMap();
|
|
const CAMERA_PRESETS = {
|
|
iso: { theta: -0.96, phi: 1.02, radius: 0.72, target: new THREE.Vector3(0, 0, 0) },
|
|
x: { theta: 0, phi: Math.PI / 2, radius: 0.64, target: new THREE.Vector3(0, 0, 0) },
|
|
y: { theta: -Math.PI / 2, phi: Math.PI / 2, radius: 0.64, target: new THREE.Vector3(0, 0, 0) },
|
|
z: { theta: 0, phi: 0.001, radius: 0.68, target: new THREE.Vector3(0, 0, 0) },
|
|
};
|
|
const MAX_TOOLPATH_POINTS = Number.POSITIVE_INFINITY;
|
|
const EMPTY_GEOMETRY = new THREE.BufferGeometry().setFromPoints([]);
|
|
const LOCAL_TOOL_AXIS = new THREE.Vector3(0, 0, 1);
|
|
|
|
export function renderFiveAxisScene(canvas, state) {
|
|
let preview = scenes.get(canvas);
|
|
if (!preview) {
|
|
preview = createPreview(canvas);
|
|
scenes.set(canvas, preview);
|
|
}
|
|
|
|
if (preview.kind === "fallback") {
|
|
renderFallbackPreview(preview, state);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
resizeRenderer(preview);
|
|
updateToolpathPreview(preview, state);
|
|
preview.renderer.render(preview.scene, preview.camera);
|
|
} catch (error) {
|
|
const fallback = createFallbackPreview(canvas, error);
|
|
scenes.set(canvas, fallback);
|
|
renderFallbackPreview(fallback, state);
|
|
return;
|
|
}
|
|
|
|
const axisReferenceMode = isAxisReferencePreview(state);
|
|
const pointCount = axisReferenceMode
|
|
? Number(state.programAxisPreviewPath?.sampleCount || 0)
|
|
: geometryPointCount(preview.previewPath.geometry);
|
|
const executedPointCount = geometryPointCount(preview.executedPath.geometry);
|
|
exposePreviewDataset(canvas, state, {
|
|
pointCount,
|
|
executedPointCount,
|
|
feedPointCount: geometryPointCount(preview.feedPath.geometry),
|
|
rapidPointCount: geometryPointCount(preview.rapidPath.geometry),
|
|
arcPointCount: geometryPointCount(preview.arcPath.geometry),
|
|
currentSegmentPointCount: geometryPointCount(preview.currentSegmentPath.geometry),
|
|
sceneObjectCount: countSceneObjects(preview.scene),
|
|
toolhead: preview.currentToolhead,
|
|
renderer: "webgl",
|
|
sceneMode: axisReferenceMode ? "linuxcnc-axis-source-preview" : "program-preview-and-tool-execution",
|
|
machineReferenceModel: axisReferenceMode ? "linuxcnc-axis-preview-reference" : "webgl-five-axis-reference",
|
|
cameraControls: preview.controls.enabled,
|
|
toolExecutionMarker: preview.toolMarker.visible,
|
|
toolAxisMarker: preview.toolAxis.visible,
|
|
pathFitBounds: preview.pathFitBoundsReady,
|
|
pathBounds: axisReferenceMode
|
|
? summarizeBounds(computePointBounds(buildProgramPreviewPoints(state)))
|
|
: computePointBoundsFromGeometryGroups([
|
|
preview.previewPath.geometry,
|
|
preview.executedPath.geometry,
|
|
preview.currentSegmentPath.geometry,
|
|
]),
|
|
vismachModel: preview.currentVismachModelState,
|
|
});
|
|
}
|
|
|
|
function createPreview(canvas) {
|
|
try {
|
|
return createScene(canvas);
|
|
} catch (error) {
|
|
return createFallbackPreview(canvas, error);
|
|
}
|
|
}
|
|
|
|
function createScene(canvas) {
|
|
const renderer = new THREE.WebGLRenderer({
|
|
canvas,
|
|
antialias: true,
|
|
preserveDrawingBuffer: true,
|
|
});
|
|
renderer.setClearColor(0x030405, 1);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(42, 1, 0.001, 10);
|
|
camera.up.set(0, 0, 1);
|
|
|
|
const machineModel = createMachineReferenceModel();
|
|
const axisReference = createAxisReferencePreviewModel();
|
|
scene.add(machineModel.root);
|
|
scene.add(axisReference.root);
|
|
|
|
const previewPath = createLine(0xffffff, 0.9);
|
|
const feedPath = createSegmentLine(0x00a8a8, 0.92);
|
|
const executedPath = createLine(0x1ffff4, 1);
|
|
const rapidPath = createSegmentLine(0x00a8a8, 0.92);
|
|
const arcPath = createSegmentLine(0xffffff, 0.95);
|
|
const currentSegmentPath = createLine(0xff4fd8, 1);
|
|
const toolMarker = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.0065, 18, 12),
|
|
new THREE.MeshBasicMaterial({ color: 0x1ffff4 }),
|
|
);
|
|
const toolAxis = new THREE.Line(
|
|
EMPTY_GEOMETRY.clone(),
|
|
new THREE.LineBasicMaterial({ color: 0x1ffff4, transparent: true, opacity: 0.9 }),
|
|
);
|
|
scene.add(previewPath, feedPath, rapidPath, arcPath, executedPath, currentSegmentPath, toolAxis, toolMarker);
|
|
|
|
const controls = createToolpathCameraControls(canvas, camera, () => {
|
|
renderer.render(scene, camera);
|
|
});
|
|
const preview = {
|
|
kind: "webgl",
|
|
renderer,
|
|
scene,
|
|
camera,
|
|
controls,
|
|
previewPath,
|
|
feedPath,
|
|
executedPath,
|
|
rapidPath,
|
|
arcPath,
|
|
currentSegmentPath,
|
|
machineModel,
|
|
axisReference,
|
|
toolMarker,
|
|
toolAxis,
|
|
currentToolhead: new THREE.Vector3(),
|
|
currentToolGlyphAxis: new THREE.Vector3(0, 0, 1),
|
|
currentVismachModelState: null,
|
|
lastSelectedView: null,
|
|
lastCameraRevision: null,
|
|
lastFitKey: null,
|
|
pathFitBoundsReady: false,
|
|
};
|
|
resizeRenderer(preview);
|
|
resetCamera(preview, "iso");
|
|
return preview;
|
|
}
|
|
|
|
function createFallbackPreview(canvas, error) {
|
|
return {
|
|
kind: "fallback",
|
|
canvas,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
|
|
function renderFallbackPreview(preview, state) {
|
|
const { canvas } = preview;
|
|
const width = Math.max(canvas.clientWidth, 320);
|
|
const height = Math.max(canvas.clientHeight, 240);
|
|
if (canvas.width !== width || canvas.height !== height) {
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
}
|
|
|
|
const previewPoints = buildProgramPreviewPoints(state);
|
|
const executedPoints = buildExecutedProgramPoints(state, previewPoints);
|
|
const rapidPoints = buildRapidPreviewPoints(state);
|
|
const feedPoints = buildTypedPreviewPoints(state, "STRAIGHT_FEED");
|
|
const arcPoints = buildTypedPreviewPoints(state, "ARC_FEED");
|
|
const currentSegmentPoints = buildCurrentSegmentPoints(state);
|
|
const pointCount = previewPoints.length;
|
|
const executedPointCount = executedPoints.length;
|
|
const toolPosition = executionToolPosition(state, previewPoints);
|
|
|
|
exposePreviewDataset(canvas, state, {
|
|
pointCount,
|
|
executedPointCount,
|
|
sceneObjectCount: 8 + (pointCount > 0 ? 1 : 0) + (executedPointCount > 0 ? 1 : 0),
|
|
toolhead: toolPosition || { x: 0, y: 0, z: 0 },
|
|
renderer: "2d-fallback",
|
|
sceneMode: "program-preview-and-tool-execution",
|
|
machineReferenceModel: "2d-five-axis-reference",
|
|
cameraControls: false,
|
|
toolExecutionMarker: Boolean(toolPosition),
|
|
toolAxisMarker: Boolean(toolPosition),
|
|
feedPointCount: feedPoints.length,
|
|
rapidPointCount: rapidPoints.length,
|
|
arcPointCount: arcPoints.length,
|
|
currentSegmentPointCount: currentSegmentPoints.length,
|
|
pathFitBounds: computePointBounds(previewPoints.concat(executedPoints, currentSegmentPoints)) !== null,
|
|
pathBounds: summarizeBounds(computePointBounds(previewPoints.concat(executedPoints, currentSegmentPoints))),
|
|
vismachModel: buildVismachModelState(state),
|
|
});
|
|
canvas.dataset.threeFallbackReason = preview.errorMessage;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.fillStyle = "#030405";
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
const cx = width * 0.5;
|
|
const cy = height * 0.53;
|
|
const scale = Math.min(width / 0.72, height / 0.48);
|
|
|
|
drawFallbackMachineReference(ctx, cx, cy, scale, state);
|
|
|
|
if (pointCount > 0) {
|
|
ctx.strokeStyle = "#8d95a0";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
drawFallbackPolyline(ctx, previewPoints, cx, cy, scale);
|
|
ctx.stroke();
|
|
}
|
|
|
|
if (executedPointCount > 0) {
|
|
ctx.strokeStyle = "#1ffff4";
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
drawFallbackPolyline(ctx, executedPoints, cx, cy, scale);
|
|
ctx.stroke();
|
|
}
|
|
if (currentSegmentPoints.length > 0) {
|
|
ctx.strokeStyle = "#ff4fd8";
|
|
ctx.lineWidth = 4;
|
|
ctx.beginPath();
|
|
drawFallbackPolyline(ctx, currentSegmentPoints, cx, cy, scale);
|
|
ctx.stroke();
|
|
}
|
|
|
|
if (toolPosition) {
|
|
const toolX = cx + toolPosition.x * scale;
|
|
const toolY = cy - toolPosition.y * scale;
|
|
ctx.fillStyle = "#1ffff4";
|
|
ctx.beginPath();
|
|
ctx.arc(toolX, toolY, 0.055 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.fillStyle = "#b7c7b8";
|
|
ctx.font = "12px Courier New, monospace";
|
|
ctx.fillText("2D RTCP fallback", 12, height - 14);
|
|
}
|
|
|
|
function exposePreviewDataset(canvas, state, preview) {
|
|
canvas.dataset.threeReady = "true";
|
|
canvas.dataset.threeRevision = THREE.REVISION;
|
|
canvas.dataset.threePathPoints = String(preview.pointCount);
|
|
canvas.dataset.threeExecutedPathPoints = String(preview.executedPointCount);
|
|
canvas.dataset.threeSceneObjects = String(preview.sceneObjectCount);
|
|
canvas.dataset.threeToolhead = JSON.stringify(toRoundedVector(preview.toolhead));
|
|
canvas.dataset.threeSceneUnits = "m";
|
|
canvas.dataset.threeLinearUnits = linearUnitsLabel(resolveSceneLinearUnits(state));
|
|
canvas.dataset.threeLinearUnitScaleToMeters = String(linearUnitsToMetersFactor(resolveSceneLinearUnits(state)));
|
|
canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector));
|
|
canvas.dataset.threeToolGlyphAxis = JSON.stringify(toRoundedVector(preview.toolGlyphAxis || preview.currentToolGlyphAxis || state.toolAxisVector));
|
|
canvas.dataset.threeTcpPose = JSON.stringify(toRoundedPose(state.tcpPose));
|
|
canvas.dataset.threeRtcpState = state.rtcpState;
|
|
canvas.dataset.threeSelectedView = state.preview.selectedView;
|
|
canvas.dataset.threeFrameApi = state.rtcpFrame.apiName;
|
|
canvas.dataset.threeRenderer = preview.renderer;
|
|
canvas.dataset.threeSceneMode = preview.sceneMode;
|
|
canvas.dataset.threePreviewScope = preview.machineReferenceModel
|
|
? "machine-reference-and-toolpath"
|
|
: "toolpath-only";
|
|
canvas.dataset.threeMachineReferenceModel = preview.machineReferenceModel || "none";
|
|
canvas.dataset.threeCameraControls = preview.cameraControls ? "orbit-pan-zoom" : "none";
|
|
canvas.dataset.threeProgramPreviewSource = previewSourceMode(state);
|
|
canvas.dataset.threeToolExecutionMarker = preview.toolExecutionMarker ? "true" : "false";
|
|
canvas.dataset.threeTcpMarker = preview.toolExecutionMarker ? "sphere" : "hidden";
|
|
canvas.dataset.threeToolAxisMarker = preview.toolAxisMarker ? "line" : "hidden";
|
|
canvas.dataset.threeToolpathPreviewSource = toolpathPreviewSource(state);
|
|
canvas.dataset.threeToolExecutionTraceSource = toolExecutionTraceSource(state);
|
|
canvas.dataset.threePathFitBounds = preview.pathFitBounds ? "ok" : "pending";
|
|
canvas.dataset.threePathBoundsMeters = JSON.stringify(preview.pathBounds || null);
|
|
canvas.dataset.threeCurrentSegmentHighlight = preview.currentSegmentPointCount > 0 ? "ok" : "pending";
|
|
canvas.dataset.threeRapidFeedVisualDistinction = preview.rapidPointCount > 0 || preview.feedPointCount > 0 || preview.arcPointCount > 0 ? "ok" : "pending";
|
|
canvas.dataset.threeNoGcodeSemanticsGeneration = "ok";
|
|
canvas.dataset.threeRapidPathPoints = String(preview.rapidPointCount || 0);
|
|
canvas.dataset.threeFeedPathPoints = String(preview.feedPointCount || 0);
|
|
canvas.dataset.threeArcPathPoints = String(preview.arcPointCount || 0);
|
|
canvas.dataset.threeCurrentSegmentPoints = String(preview.currentSegmentPointCount || 0);
|
|
canvas.dataset.threeCurrentSegmentType = currentSegmentType(state);
|
|
canvas.dataset.threeVismachPins = JSON.stringify(preview.vismachModel?.pins || {});
|
|
canvas.dataset.threeVismachTransforms = JSON.stringify(preview.vismachModel?.transforms || {});
|
|
canvas.dataset.threeVismachPinDrivenModel = preview.vismachModel?.semanticBoundary || "none";
|
|
}
|
|
|
|
function createLine(color, opacity) {
|
|
return new THREE.Line(
|
|
EMPTY_GEOMETRY.clone(),
|
|
new THREE.LineBasicMaterial({
|
|
color,
|
|
transparent: opacity < 1,
|
|
opacity,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function createSegmentLine(color, opacity) {
|
|
return new THREE.LineSegments(
|
|
EMPTY_GEOMETRY.clone(),
|
|
new THREE.LineBasicMaterial({
|
|
color,
|
|
transparent: opacity < 1,
|
|
opacity,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function createMachineReferenceModel() {
|
|
const root = new THREE.Group();
|
|
root.name = "five-axis-machine-reference";
|
|
|
|
const base = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.48, 0.32, 0.008),
|
|
new THREE.MeshBasicMaterial({ color: 0x222930 }),
|
|
);
|
|
base.position.z = -0.016;
|
|
|
|
const tableAxis = new THREE.Group();
|
|
tableAxis.name = "vismach-table-x";
|
|
const saddleAxis = new THREE.Group();
|
|
saddleAxis.name = "vismach-saddle-y";
|
|
const spindleAxis = new THREE.Group();
|
|
spindleAxis.name = "vismach-spindle-z";
|
|
const tiltAxis = new THREE.Group();
|
|
tiltAxis.name = "vismach-tilt-b";
|
|
const rotaryAxis = new THREE.Group();
|
|
rotaryAxis.name = "vismach-rotate-c";
|
|
const toolOffsetAxis = new THREE.Group();
|
|
toolOffsetAxis.name = "vismach-tool-offset";
|
|
|
|
const table = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.37, 0.235, 0.005),
|
|
new THREE.MeshBasicMaterial({ color: 0x3a444d, transparent: true, opacity: 0.78 }),
|
|
);
|
|
table.position.z = -0.008;
|
|
|
|
const xAxis = createStaticLine([new THREE.Vector3(-0.22, 0, 0), new THREE.Vector3(0.225, 0, 0)], 0xff4d4d);
|
|
const yAxis = createStaticLine([new THREE.Vector3(0, -0.155, 0), new THREE.Vector3(0, 0.16, 0)], 0x70df7d);
|
|
const zAxis = createStaticLine([new THREE.Vector3(0, 0, -0.008), new THREE.Vector3(0, 0, 0.175)], 0x5aa7ff);
|
|
|
|
const rotaryA = new THREE.Mesh(
|
|
new THREE.TorusGeometry(0.088, 0.0018, 8, 72),
|
|
new THREE.MeshBasicMaterial({ color: 0x1ffff4, transparent: true, opacity: 0.92 }),
|
|
);
|
|
rotaryA.rotation.y = Math.PI / 2;
|
|
|
|
const rotaryC = new THREE.Mesh(
|
|
new THREE.TorusGeometry(0.11, 0.0016, 8, 72),
|
|
new THREE.MeshBasicMaterial({ color: 0xffd166, transparent: true, opacity: 0.9 }),
|
|
);
|
|
rotaryC.rotation.x = Math.PI / 2;
|
|
rotaryC.position.z = 0.004;
|
|
|
|
const toolHolder = new THREE.Group();
|
|
const holderBody = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.008, 0.008, 0.042, 18),
|
|
new THREE.MeshBasicMaterial({ color: 0xf1f5f9 }),
|
|
);
|
|
holderBody.rotation.x = Math.PI / 2;
|
|
holderBody.position.z = 0.046;
|
|
const cutter = new THREE.Mesh(
|
|
new THREE.ConeGeometry(0.006, 0.025, 18),
|
|
new THREE.MeshBasicMaterial({ color: 0xfff176 }),
|
|
);
|
|
cutter.rotation.x = -Math.PI / 2;
|
|
cutter.position.z = 0.0125;
|
|
toolHolder.add(holderBody, cutter);
|
|
|
|
toolOffsetAxis.add(toolHolder);
|
|
spindleAxis.add(toolOffsetAxis);
|
|
rotaryAxis.add(table, rotaryC);
|
|
tiltAxis.add(rotaryA, rotaryAxis);
|
|
saddleAxis.add(tiltAxis);
|
|
tableAxis.add(saddleAxis);
|
|
root.add(base, xAxis, yAxis, zAxis, tableAxis, spindleAxis);
|
|
return {
|
|
root,
|
|
tableAxis,
|
|
saddleAxis,
|
|
spindleAxis,
|
|
tiltAxis,
|
|
rotaryAxis,
|
|
toolOffsetAxis,
|
|
rotaryA,
|
|
rotaryC,
|
|
toolHolder,
|
|
};
|
|
}
|
|
|
|
function createAxisReferencePreviewModel() {
|
|
const root = new THREE.Group();
|
|
root.name = "axis-native-preview-reference";
|
|
root.visible = false;
|
|
|
|
const zLift = 0.0004;
|
|
const xAxis = createStaticLine([
|
|
new THREE.Vector3(-0.024, 0, zLift),
|
|
new THREE.Vector3(0.036, 0, zLift),
|
|
], 0x00ff00);
|
|
const yAxis = createStaticLine([
|
|
new THREE.Vector3(0, -0.024, zLift),
|
|
new THREE.Vector3(0, 0.036, zLift),
|
|
], 0xff2020);
|
|
const zAxis = createStaticLine([
|
|
new THREE.Vector3(0, 0, 0),
|
|
new THREE.Vector3(0, 0, 0.032),
|
|
], 0x3030ff);
|
|
|
|
const dimensions = createDimensionLines();
|
|
const labels = [
|
|
createTextSprite("X", 0x00ff00, new THREE.Vector3(0.039, 0, zLift), 0.0034),
|
|
createTextSprite("Y", 0xff2020, new THREE.Vector3(0, 0.039, zLift), 0.0034),
|
|
createTextSprite("Z", 0x3030ff, new THREE.Vector3(0, 0, 0.032), 0.0034),
|
|
createTextSprite("60.0", 0xff7070, new THREE.Vector3(0, -0.0355, zLift), 0.0026),
|
|
createTextSprite("60.0", 0xff7070, new THREE.Vector3(-0.0355, 0, zLift), 0.0026),
|
|
createTextSprite("30.0", 0xff7070, new THREE.Vector3(-0.028, 0.014, zLift), 0.0026),
|
|
createTextSprite("30.0", 0xff7070, new THREE.Vector3(-0.014, -0.028, zLift), 0.0026),
|
|
];
|
|
|
|
const tool = new THREE.Group();
|
|
tool.name = "axis-native-tool-glyph";
|
|
const cone = new THREE.Mesh(
|
|
new THREE.ConeGeometry(0.0017, 0.0048, 4),
|
|
new THREE.MeshBasicMaterial({ color: 0xe7eef7 }),
|
|
);
|
|
cone.rotation.x = -Math.PI / 2;
|
|
cone.position.z = 0.0024;
|
|
const holder = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.0011, 0.0011, 0.0065, 8),
|
|
new THREE.MeshBasicMaterial({ color: 0xbfc8d0 }),
|
|
);
|
|
holder.rotation.x = Math.PI / 2;
|
|
holder.position.z = 0.00805;
|
|
tool.add(cone, holder);
|
|
|
|
root.add(xAxis, yAxis, zAxis, dimensions, tool, ...labels);
|
|
return {
|
|
root,
|
|
tool,
|
|
};
|
|
}
|
|
|
|
function createDimensionLines() {
|
|
const group = new THREE.Group();
|
|
group.name = "axis-native-preview-dimensions";
|
|
const z = 0.0002;
|
|
const lines = [
|
|
[new THREE.Vector3(-0.030, -0.033, z), new THREE.Vector3(0.030, -0.033, z)],
|
|
[new THREE.Vector3(-0.030, -0.0355, z), new THREE.Vector3(-0.030, -0.0305, z)],
|
|
[new THREE.Vector3(0.030, -0.0355, z), new THREE.Vector3(0.030, -0.0305, z)],
|
|
[new THREE.Vector3(-0.033, -0.030, z), new THREE.Vector3(-0.033, 0.030, z)],
|
|
[new THREE.Vector3(-0.0355, -0.030, z), new THREE.Vector3(-0.0305, -0.030, z)],
|
|
[new THREE.Vector3(-0.0355, 0.030, z), new THREE.Vector3(-0.0305, 0.030, z)],
|
|
[new THREE.Vector3(-0.030, 0.030, z), new THREE.Vector3(0, 0.030, z)],
|
|
[new THREE.Vector3(-0.030, -0.030, z), new THREE.Vector3(-0.030, 0, z)],
|
|
];
|
|
for (const [start, end] of lines) {
|
|
group.add(createStaticLine([start, end], 0xff3030));
|
|
}
|
|
return group;
|
|
}
|
|
|
|
function createTextSprite(text, color, position, height) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 192;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.font = "32px Courier New, monospace";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillStyle = `#${color.toString(16).padStart(6, "0")}`;
|
|
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.needsUpdate = true;
|
|
const material = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
});
|
|
const sprite = new THREE.Sprite(material);
|
|
sprite.position.copy(position);
|
|
sprite.scale.set(height * 3, height, 1);
|
|
return sprite;
|
|
}
|
|
|
|
function createStaticLine(points, color) {
|
|
return new THREE.Line(
|
|
new THREE.BufferGeometry().setFromPoints(points),
|
|
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.95 }),
|
|
);
|
|
}
|
|
|
|
function updateToolpathPreview(preview, state) {
|
|
const axisReferenceMode = isAxisReferencePreview(state);
|
|
const previewPoints = buildProgramPreviewPoints(state);
|
|
const executedPoints = buildExecutedProgramPoints(state, previewPoints);
|
|
const rapidPoints = buildRapidPreviewPoints(state);
|
|
const feedPoints = buildTypedPreviewPoints(state, "STRAIGHT_FEED");
|
|
const arcPoints = buildTypedPreviewPoints(state, "ARC_FEED");
|
|
const currentSegmentPoints = buildCurrentSegmentPoints(state);
|
|
const toolPosition = executionToolPosition(state, previewPoints);
|
|
const fitPoints = collectFitPoints(previewPoints, executedPoints, currentSegmentPoints, toolPosition);
|
|
const fitKey = [
|
|
previewPoints.length,
|
|
executedPoints.length,
|
|
currentSegmentPoints.length,
|
|
previewSourceMode(state),
|
|
state.programExecutionMotionIndex || 0,
|
|
state.programExecutionSampleIndex || 0,
|
|
].join(":");
|
|
|
|
if (axisReferenceMode) {
|
|
updateLineGeometry(preview.previewPath, []);
|
|
updateLineGeometry(preview.feedPath, []);
|
|
updateLineGeometry(preview.executedPath, []);
|
|
updateLineSegmentsGeometry(preview.rapidPath, rapidPoints);
|
|
updateLineSegmentsGeometry(preview.arcPath, arcPoints);
|
|
updateLineGeometry(preview.currentSegmentPath, []);
|
|
updateToolExecutionMarker(preview, state, null);
|
|
} else {
|
|
updateLineGeometry(preview.previewPath, previewPoints);
|
|
updateLineGeometry(preview.feedPath, feedPoints);
|
|
updateLineGeometry(preview.executedPath, executedPoints);
|
|
updateLineSegmentsGeometry(preview.rapidPath, rapidPoints);
|
|
updateLineSegmentsGeometry(preview.arcPath, arcPoints);
|
|
updateLineGeometry(preview.currentSegmentPath, currentSegmentPoints);
|
|
updateToolExecutionMarker(preview, state, toolPosition);
|
|
}
|
|
updateMachineReferenceModel(preview, state, toolPosition, axisReferenceMode);
|
|
|
|
const cameraRevision = state.preview.cameraRevision ?? 0;
|
|
if (
|
|
preview.lastSelectedView !== state.preview.selectedView ||
|
|
preview.lastCameraRevision !== cameraRevision ||
|
|
preview.lastFitKey !== fitKey
|
|
) {
|
|
resetCamera(preview, state.preview.selectedView, fitPoints, axisReferenceMode);
|
|
preview.lastSelectedView = state.preview.selectedView;
|
|
preview.lastCameraRevision = cameraRevision;
|
|
preview.lastFitKey = fitKey;
|
|
} else {
|
|
applyCameraControls(preview.controls);
|
|
}
|
|
}
|
|
|
|
function updateToolExecutionMarker(preview, state, toolPosition) {
|
|
if (!toolPosition) {
|
|
preview.currentToolhead.set(0, 0, 0);
|
|
preview.toolMarker.visible = false;
|
|
preview.toolAxis.visible = false;
|
|
updateLineGeometry(preview.toolAxis, []);
|
|
return;
|
|
}
|
|
|
|
const vector = toToolVector(state.toolAxisVector);
|
|
preview.currentToolhead.copy(toolPosition);
|
|
preview.toolMarker.visible = true;
|
|
preview.toolMarker.position.copy(toolPosition);
|
|
preview.toolAxis.visible = true;
|
|
updateLineGeometry(preview.toolAxis, [
|
|
toolPosition,
|
|
toolPosition.clone().add(vector.multiplyScalar(0.07)),
|
|
]);
|
|
}
|
|
|
|
function updateMachineReferenceModel(preview, state, toolPosition, axisReferenceMode = false) {
|
|
const model = preview.machineModel;
|
|
if (!model) return;
|
|
model.root.visible = !axisReferenceMode;
|
|
if (preview.axisReference) {
|
|
preview.axisReference.root.visible = axisReferenceMode;
|
|
if (axisReferenceMode) {
|
|
const referenceToolPosition = new THREE.Vector3(0, 0, 0.006);
|
|
const toolVector = toToolVector(state.toolAxisVector);
|
|
preview.axisReference.tool.position.copy(referenceToolPosition);
|
|
alignToolGlyphToAxis(preview.axisReference.tool, toolVector);
|
|
preview.currentToolGlyphAxis.copy(toolVector);
|
|
}
|
|
}
|
|
if (axisReferenceMode) {
|
|
preview.currentVismachModelState = null;
|
|
return;
|
|
}
|
|
const vismach = buildVismachModelState(state);
|
|
preview.currentVismachModelState = summarizeVismachModelStateForDataset(vismach);
|
|
const toMeters = (value) => linearValueToMeters(value, vismach.linearUnits);
|
|
model.tableAxis.position.x = toMeters(vismach.transforms.table.translate.x);
|
|
model.saddleAxis.position.y = toMeters(vismach.transforms.saddle.translate.y);
|
|
model.spindleAxis.position.z = toMeters(vismach.transforms.spindle.translate.z);
|
|
model.tiltAxis.rotation.y = degreesToRadians(vismach.transforms.tilt.rotateDeg.y);
|
|
model.rotaryAxis.rotation.z = degreesToRadians(vismach.transforms.rotary.rotateDeg.z);
|
|
model.toolOffsetAxis.position.set(
|
|
toMeters(vismach.transforms.tool.translate.x),
|
|
0,
|
|
toMeters(vismach.transforms.tool.translate.z),
|
|
);
|
|
model.rotaryA.rotation.y = Math.PI / 2;
|
|
model.rotaryC.rotation.x = Math.PI / 2;
|
|
|
|
const tcpPosition = toolPosition || toPreviewVector(state.tcpPose || state.axisPose, state);
|
|
const toolVector = toToolVector(state.toolAxisVector);
|
|
model.toolHolder.position.copy(tcpPosition);
|
|
alignToolGlyphToAxis(model.toolHolder, toolVector);
|
|
preview.currentToolGlyphAxis.copy(toolVector);
|
|
}
|
|
|
|
function alignToolGlyphToAxis(object, toolVector) {
|
|
const axis = toolVector.clone().normalize();
|
|
if (!Number.isFinite(axis.x) || !Number.isFinite(axis.y) || !Number.isFinite(axis.z) || axis.lengthSq() === 0) {
|
|
axis.copy(LOCAL_TOOL_AXIS);
|
|
}
|
|
object.quaternion.setFromUnitVectors(LOCAL_TOOL_AXIS, axis);
|
|
}
|
|
|
|
function updateLineGeometry(line, points) {
|
|
line.visible = points.length > 0;
|
|
line.geometry.dispose();
|
|
line.geometry = points.length > 0
|
|
? new THREE.BufferGeometry().setFromPoints(points)
|
|
: EMPTY_GEOMETRY.clone();
|
|
}
|
|
|
|
function updateLineSegmentsGeometry(line, segmentPoints) {
|
|
line.visible = segmentPoints.length > 0;
|
|
line.geometry.dispose();
|
|
line.geometry = segmentPoints.length > 0
|
|
? new THREE.BufferGeometry().setFromPoints(segmentPoints)
|
|
: EMPTY_GEOMETRY.clone();
|
|
}
|
|
|
|
function geometryPointCount(geometry) {
|
|
return geometry?.getAttribute("position")?.count || 0;
|
|
}
|
|
|
|
function buildProgramPreviewPoints(state) {
|
|
const previewSamples = state.programAxisPreviewPath?.samples;
|
|
if (Array.isArray(previewSamples) && previewSamples.length > 0 && state.preview.pathPoints !== 0) {
|
|
return limitPoints(previewSamples.map((sample) => vectorFromAxes(sample.tcp || sample.joint, state, "mm")));
|
|
}
|
|
|
|
const motion = state.programExecution?.motion;
|
|
if (Array.isArray(motion) && motion.length > 0 && state.preview.pathPoints !== 0) {
|
|
return limitPoints(motion.map((event) => vectorFromAxes(event.axes, state, event.linearUnits)));
|
|
}
|
|
|
|
const pointCount = normalizePathPointCount(state.preview.pathPoints);
|
|
if (pointCount === 0) return [];
|
|
return buildFixturePreviewPoints(pointCount, toPreviewVector(state.tcpPose, state));
|
|
}
|
|
|
|
function buildExecutedProgramPoints(state, previewPoints) {
|
|
if (state.preview.pathPoints === 0 || previewPoints.length === 0) return [];
|
|
const previewSamples = state.programAxisPreviewPath?.samples;
|
|
if (Array.isArray(previewSamples) && previewSamples.length > 0) {
|
|
const end = clamp(Math.round(Number(state.programExecutionSampleIndex || 0)), 0, previewSamples.length - 1);
|
|
return previewPoints.slice(0, end + 1);
|
|
}
|
|
|
|
const samples = state.programExecutionTiming?.samples;
|
|
const sampleIndex = Number(state.programExecutionSampleIndex || 0);
|
|
if (Array.isArray(samples) && samples.length > 0) {
|
|
const end = clamp(Math.round(sampleIndex), 0, samples.length - 1);
|
|
return limitPoints(samples.slice(0, end + 1).map((sample) => vectorFromAxes(sample, state, sample.linearUnits)));
|
|
}
|
|
|
|
const motionIndex = clamp(Math.round(Number(state.programExecutionMotionIndex || 0)), 0, previewPoints.length - 1);
|
|
return previewPoints.slice(0, motionIndex + 1);
|
|
}
|
|
|
|
function buildRapidPreviewPoints(state) {
|
|
return buildTypedPreviewPoints(state, "STRAIGHT_TRAVERSE");
|
|
}
|
|
|
|
function buildTypedPreviewPoints(state, type) {
|
|
const previewSamples = state.programAxisPreviewPath?.samples;
|
|
if (Array.isArray(previewSamples) && previewSamples.length > 0 && state.preview.pathPoints !== 0) {
|
|
const expectedMotionType = type === "STRAIGHT_TRAVERSE"
|
|
? "rapid"
|
|
: type === "ARC_FEED"
|
|
? "arc"
|
|
: "feed";
|
|
return limitPoints(buildAxisSampleLineSegments(previewSamples, state, expectedMotionType));
|
|
}
|
|
|
|
const motion = state.programExecution?.motion;
|
|
if (!Array.isArray(motion) || state.preview.pathPoints === 0) return [];
|
|
return limitPoints(
|
|
motion
|
|
.filter((event) => event.type === type)
|
|
.flatMap((event, index, events) => {
|
|
const previous = events[Math.max(index - 1, 0)];
|
|
return [
|
|
vectorFromAxes(previous.axes || event.axes, state, previous.linearUnits || event.linearUnits),
|
|
vectorFromAxes(event.axes, state, event.linearUnits),
|
|
];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function buildAxisSampleLineSegments(samples, state, motionType) {
|
|
const points = [];
|
|
for (let index = 1; index < samples.length; index += 1) {
|
|
const previous = samples[index - 1];
|
|
const current = samples[index];
|
|
if (previous.motionType !== motionType || current.motionType !== motionType) continue;
|
|
if (current.line !== previous.line && motionType === "arc") continue;
|
|
points.push(
|
|
vectorFromAxes(previous.tcp || previous.joint, state, "mm"),
|
|
vectorFromAxes(current.tcp || current.joint, state, "mm"),
|
|
);
|
|
}
|
|
return points;
|
|
}
|
|
|
|
function buildCurrentSegmentPoints(state) {
|
|
if (state.preview.pathPoints === 0) return [];
|
|
const previewSamples = state.programAxisPreviewPath?.samples;
|
|
if (Array.isArray(previewSamples) && previewSamples.length > 0) {
|
|
const sampleIndex = clamp(Math.round(Number(state.programExecutionSampleIndex || 0)), 0, previewSamples.length - 1);
|
|
const current = previewSamples[sampleIndex];
|
|
const previous = previewSamples[Math.max(sampleIndex - 1, 0)];
|
|
return [previous, current]
|
|
.filter(Boolean)
|
|
.map((sample) => vectorFromAxes(sample.tcp || sample.joint, state, "mm"));
|
|
}
|
|
|
|
const motion = state.programExecution?.motion;
|
|
if (!Array.isArray(motion) || motion.length === 0) return [];
|
|
const motionIndex = clampMotionIndex(state, currentMotionIndex(state));
|
|
const current = motion[motionIndex];
|
|
const previous = motion[Math.max(motionIndex - 1, 0)];
|
|
if (!current) return [];
|
|
const start = motionIndex === 0
|
|
? vectorFromAxes(previous?.axes || current.axes, state, previous?.linearUnits || current.linearUnits)
|
|
: vectorFromAxes(previous.axes, state, previous.linearUnits);
|
|
const end = vectorFromAxes(current.axes, state, current.linearUnits);
|
|
return start.distanceTo(end) > 0 ? [start, end] : [end];
|
|
}
|
|
|
|
function buildFixturePreviewPoints(pointCount, tcpPosition) {
|
|
const points = [];
|
|
for (let index = 0; index < pointCount; index += 1) {
|
|
const t = pointCount === 1 ? 0 : index / (pointCount - 1);
|
|
const x = -0.085 + t * 0.17;
|
|
const y = Math.sin(t * Math.PI * 13) * 0.012;
|
|
const z = 0.012 + Math.sin(t * Math.PI * 2) * 0.018;
|
|
points.push(new THREE.Vector3(x, y, z));
|
|
}
|
|
if (points.length > 0 && tcpPosition) {
|
|
points[points.length - 1] = tcpPosition.clone();
|
|
}
|
|
return points;
|
|
}
|
|
|
|
function executionToolPosition(state, previewPoints) {
|
|
const uiTcp = state.programUiExecution?.tcp;
|
|
if (uiTcp && hasLinearAxes(uiTcp)) {
|
|
return vectorFromAxes(uiTcp, state, state.programRuntimeFeedback?.linearUnits || "mm");
|
|
}
|
|
const feedbackTcp = state.programRuntimeFeedback?.tcp;
|
|
if (feedbackTcp && hasLinearAxes(feedbackTcp)) {
|
|
return vectorFromAxes(feedbackTcp, state, state.programRuntimeFeedback?.linearUnits || "mm");
|
|
}
|
|
const feedbackAxes = state.programRuntimeFeedback?.axisPose || state.programRuntimeFeedback;
|
|
if (feedbackAxes && hasLinearAxes(feedbackAxes)) {
|
|
return vectorFromAxes(feedbackAxes, state, state.programRuntimeFeedback?.linearUnits);
|
|
}
|
|
if (hasLinearAxes(state.axisPose)) return vectorFromAxes(state.axisPose, state);
|
|
return previewPoints.at(-1) || null;
|
|
}
|
|
|
|
export function axesToSceneMeters(axes = {}, state = {}, linearUnits = null) {
|
|
const units = linearUnits || axes.linearUnits || resolveSceneLinearUnits(state);
|
|
return {
|
|
x: linearValueToMeters(axes.x, units),
|
|
y: linearValueToMeters(axes.y, units),
|
|
z: linearValueToMeters(axes.z, units),
|
|
};
|
|
}
|
|
|
|
function vectorFromAxes(axes = {}, state = {}, linearUnits = null) {
|
|
const point = axesToSceneMeters(axes, state, linearUnits);
|
|
return new THREE.Vector3(
|
|
point.x,
|
|
point.y,
|
|
point.z,
|
|
);
|
|
}
|
|
|
|
function toPreviewVector(pose = {}, state = {}) {
|
|
const point = axesToSceneMeters(pose, state);
|
|
return new THREE.Vector3(
|
|
point.x,
|
|
point.y,
|
|
point.z,
|
|
);
|
|
}
|
|
|
|
function resolveSceneLinearUnits(state) {
|
|
return resolveStateLinearUnits(state);
|
|
}
|
|
|
|
function toToolVector(vector = {}) {
|
|
return new THREE.Vector3(
|
|
Number(vector.x) || 0,
|
|
Number(vector.y) || 0,
|
|
Number(vector.z) || 1,
|
|
).normalize();
|
|
}
|
|
|
|
function hasLinearAxes(value = {}) {
|
|
return ["x", "y", "z"].some((axis) => Number.isFinite(Number(value[axis])));
|
|
}
|
|
|
|
function limitPoints(points) {
|
|
if (points.length <= MAX_TOOLPATH_POINTS) return points;
|
|
const stride = Math.ceil(points.length / MAX_TOOLPATH_POINTS);
|
|
const sampled = points.filter((_, index) => index % stride === 0);
|
|
const last = points.at(-1);
|
|
if (last && sampled.at(-1) !== last) sampled.push(last);
|
|
return sampled;
|
|
}
|
|
|
|
function previewSourceMode(state) {
|
|
if (state.programExecution?.sourceMode) return state.programExecution.sourceMode;
|
|
return state.programExecutionSourceMode || "fixture-line-playback";
|
|
}
|
|
|
|
function toolpathPreviewSource(state) {
|
|
if (isAxisReferencePreview(state)) {
|
|
return "axis_preview_expanded_ngcgui_subroutines";
|
|
}
|
|
if (state.programExecution?.sourceMode === "linuxcnc-interpreter-wasm") {
|
|
return "linuxcnc_interpreter_canonical_motion";
|
|
}
|
|
if (state.programExecution?.sourceMode === "linuxcnc-machine-file-remap-wasm") {
|
|
return "linuxcnc_machine_file_remap_canonical_motion";
|
|
}
|
|
return "fixture_line_playback_not_promoted";
|
|
}
|
|
|
|
function isAxisReferencePreview(state) {
|
|
return state.programAxisPreviewPath?.source === "web-axis-preview-expanded-ngcgui-subroutines"
|
|
|| state.programAxisPreviewPath?.source === "web-axis-source-execution-expanded-ngcgui-subroutines"
|
|
|| Boolean(state.programAxisPreviewPath?.gcodeExecutionProcess);
|
|
}
|
|
|
|
function toolExecutionTraceSource(state) {
|
|
if (Array.isArray(state.programExecutionTiming?.samples) && state.programExecutionTiming.samples.length > 0) {
|
|
return "linuxcnc_tp_samples_or_task_motion_hal_feedback";
|
|
}
|
|
if (state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm") {
|
|
return "linuxcnc_tp_samples_or_task_motion_hal_feedback";
|
|
}
|
|
return "fixture_line_playback_not_promoted";
|
|
}
|
|
|
|
function currentSegmentType(state) {
|
|
const motion = state.programExecution?.motion;
|
|
if (!Array.isArray(motion) || motion.length === 0) return "-";
|
|
return motion[clampMotionIndex(state, currentMotionIndex(state))]?.type || "-";
|
|
}
|
|
|
|
function currentMotionIndex(state) {
|
|
const sample = state.programExecutionTiming?.samples?.[Number(state.programExecutionSampleIndex || 0)];
|
|
if (Number.isFinite(Number(sample?.motionIndex))) return Number(sample.motionIndex);
|
|
return Number(state.programExecutionMotionIndex || 0);
|
|
}
|
|
|
|
function clampMotionIndex(state, index) {
|
|
const count = state.programExecution?.motion?.length || 0;
|
|
if (count <= 0) return 0;
|
|
return clamp(Math.round(Number(index) || 0), 0, count - 1);
|
|
}
|
|
|
|
function collectFitPoints(...groups) {
|
|
return groups.flatMap((group) => {
|
|
if (!group) return [];
|
|
if (Array.isArray(group)) return group.filter(Boolean);
|
|
return [group];
|
|
});
|
|
}
|
|
|
|
function computePointBounds(points) {
|
|
const valid = points.filter((point) => point && Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z));
|
|
if (valid.length === 0) return null;
|
|
const box = new THREE.Box3().setFromPoints(valid);
|
|
if (box.isEmpty()) return null;
|
|
return box;
|
|
}
|
|
|
|
function computePointBoundsFromGeometryGroups(geometries) {
|
|
const points = [];
|
|
for (const geometry of geometries) {
|
|
const position = geometry?.getAttribute("position");
|
|
if (!position) continue;
|
|
for (let index = 0; index < position.count; index += 1) {
|
|
points.push(new THREE.Vector3(
|
|
position.getX(index),
|
|
position.getY(index),
|
|
position.getZ(index),
|
|
));
|
|
}
|
|
}
|
|
return summarizeBounds(computePointBounds(points));
|
|
}
|
|
|
|
function summarizeBounds(bounds) {
|
|
if (!bounds) return null;
|
|
const center = new THREE.Vector3();
|
|
const size = new THREE.Vector3();
|
|
bounds.getCenter(center);
|
|
bounds.getSize(size);
|
|
return {
|
|
center: toRoundedVector(center),
|
|
size: toRoundedVector(size),
|
|
maxSpan: Number(Math.max(size.x, size.y, size.z).toFixed(6)),
|
|
};
|
|
}
|
|
|
|
function summarizeVismachModelStateForDataset(vismach) {
|
|
const summarizeVector = (vector = {}) => ({
|
|
x: round(vector.x),
|
|
y: round(vector.y),
|
|
z: round(vector.z),
|
|
});
|
|
return {
|
|
...vismach,
|
|
pins: Object.fromEntries(Object.entries(vismach.pins).map(([key, value]) => [key, round(value)])),
|
|
transforms: {
|
|
table: { ...vismach.transforms.table, translate: summarizeVector(vismach.transforms.table.translate) },
|
|
saddle: { ...vismach.transforms.saddle, translate: summarizeVector(vismach.transforms.saddle.translate) },
|
|
spindle: { ...vismach.transforms.spindle, translate: summarizeVector(vismach.transforms.spindle.translate) },
|
|
tilt: { ...vismach.transforms.tilt },
|
|
rotary: { ...vismach.transforms.rotary },
|
|
tool: { ...vismach.transforms.tool, translate: summarizeVector(vismach.transforms.tool.translate) },
|
|
},
|
|
};
|
|
}
|
|
|
|
function drawFallbackPolyline(ctx, points, cx, cy, scale) {
|
|
for (let index = 0; index < points.length; index += 1) {
|
|
const point = points[index];
|
|
const x = cx + point.x * scale;
|
|
const y = cy - point.y * scale;
|
|
if (index === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
|
|
function drawFallbackMachineReference(ctx, cx, cy, scale, state) {
|
|
const tableWidth = 0.48 * scale;
|
|
const tableHeight = 0.32 * scale;
|
|
ctx.fillStyle = "#20272e";
|
|
ctx.strokeStyle = "#56616b";
|
|
ctx.lineWidth = 2;
|
|
ctx.fillRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight);
|
|
ctx.strokeRect(cx - tableWidth / 2, cy - tableHeight / 2, tableWidth, tableHeight);
|
|
|
|
drawFallbackAxis(ctx, cx - 0.225 * scale, cy, cx + 0.225 * scale, cy, "#ff4d4d");
|
|
drawFallbackAxis(ctx, cx, cy + 0.155 * scale, cx, cy - 0.16 * scale, "#70df7d");
|
|
drawFallbackAxis(ctx, cx, cy + 0.02 * scale, cx, cy - 0.115 * scale, "#5aa7ff");
|
|
|
|
ctx.strokeStyle = "#1ffff4";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy, 0.092 * scale, 0.042 * scale, degreesToRadians(state.axisPose?.a), 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = "#ffd166";
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, 0.07 * scale, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
const tcp = executionToolPosition(state, []);
|
|
if (tcp) {
|
|
const toolX = cx + tcp.x * scale;
|
|
const toolY = cy - tcp.y * scale;
|
|
ctx.strokeStyle = "#f1f5f9";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(toolX, toolY - 0.038 * scale);
|
|
ctx.lineTo(toolX, toolY - 0.008 * scale);
|
|
ctx.stroke();
|
|
ctx.fillStyle = "#1ffff4";
|
|
ctx.beginPath();
|
|
ctx.arc(toolX, toolY, 0.007 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
function drawFallbackAxis(ctx, x1, y1, x2, y2, color) {
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function createToolpathCameraControls(canvas, camera, renderFrame) {
|
|
const controls = {
|
|
enabled: true,
|
|
camera,
|
|
target: new THREE.Vector3(),
|
|
theta: CAMERA_PRESETS.iso.theta,
|
|
phi: CAMERA_PRESETS.iso.phi,
|
|
radius: CAMERA_PRESETS.iso.radius,
|
|
pointerMode: null,
|
|
pointers: new Map(),
|
|
lastPointer: null,
|
|
lastPinchCenter: null,
|
|
lastPinchDistance: 0,
|
|
renderFrame,
|
|
};
|
|
|
|
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
canvas.addEventListener("wheel", (event) => {
|
|
event.preventDefault();
|
|
const scale = Math.exp(Math.sign(event.deltaY) * 0.12);
|
|
controls.radius = clamp(controls.radius * scale, 0.06, 4);
|
|
applyCameraControls(controls);
|
|
controls.renderFrame();
|
|
}, { passive: false });
|
|
canvas.addEventListener("pointerdown", (event) => {
|
|
safelySetPointerCapture(canvas, event.pointerId);
|
|
controls.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
controls.lastPointer = { x: event.clientX, y: event.clientY };
|
|
controls.pointerMode = event.button === 1 || event.button === 2 || event.shiftKey ? "pan" : "rotate";
|
|
if (controls.pointers.size === 2) {
|
|
controls.pointerMode = "pinch";
|
|
controls.lastPinchDistance = getPointerDistance(controls.pointers);
|
|
controls.lastPinchCenter = getPointerCenter(controls.pointers);
|
|
}
|
|
});
|
|
canvas.addEventListener("pointermove", (event) => {
|
|
if (!controls.pointers.has(event.pointerId)) return;
|
|
const previous = controls.pointers.get(event.pointerId);
|
|
controls.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
|
|
if (controls.pointerMode === "pinch" && controls.pointers.size >= 2) {
|
|
const distance = getPointerDistance(controls.pointers);
|
|
const center = getPointerCenter(controls.pointers);
|
|
if (distance > 0 && controls.lastPinchDistance > 0) {
|
|
controls.radius = clamp(controls.radius * (controls.lastPinchDistance / distance), 0.06, 4);
|
|
if (controls.lastPinchCenter) {
|
|
panCamera(
|
|
controls,
|
|
center.x - controls.lastPinchCenter.x,
|
|
center.y - controls.lastPinchCenter.y,
|
|
canvas,
|
|
);
|
|
}
|
|
controls.lastPinchDistance = distance;
|
|
controls.lastPinchCenter = center;
|
|
applyCameraControls(controls);
|
|
controls.renderFrame();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const dx = event.clientX - previous.x;
|
|
const dy = event.clientY - previous.y;
|
|
if (controls.pointerMode === "pan") {
|
|
panCamera(controls, dx, dy, canvas);
|
|
} else {
|
|
controls.theta -= dx * 0.006;
|
|
controls.phi = clamp(controls.phi - dy * 0.006, 0.001, Math.PI - 0.001);
|
|
}
|
|
applyCameraControls(controls);
|
|
controls.renderFrame();
|
|
});
|
|
|
|
const releasePointer = (event) => {
|
|
controls.pointers.delete(event.pointerId);
|
|
safelyReleasePointerCapture(canvas, event.pointerId);
|
|
if (controls.pointers.size === 0) {
|
|
controls.pointerMode = null;
|
|
controls.lastPointer = null;
|
|
controls.lastPinchCenter = null;
|
|
controls.lastPinchDistance = 0;
|
|
}
|
|
};
|
|
canvas.addEventListener("pointerup", releasePointer);
|
|
canvas.addEventListener("pointercancel", releasePointer);
|
|
return controls;
|
|
}
|
|
|
|
function safelySetPointerCapture(canvas, pointerId) {
|
|
try {
|
|
canvas.setPointerCapture?.(pointerId);
|
|
} catch {
|
|
// Synthetic browser-smoke events do not always have an active pointer capture target.
|
|
}
|
|
}
|
|
|
|
function safelyReleasePointerCapture(canvas, pointerId) {
|
|
try {
|
|
canvas.releasePointerCapture?.(pointerId);
|
|
} catch {
|
|
// Matching setPointerCapture guard for synthetic and cancelled pointer streams.
|
|
}
|
|
}
|
|
|
|
function normalizePathPointCount(value) {
|
|
const requestedCount = Number(value);
|
|
return Number.isFinite(requestedCount) && requestedCount > 0
|
|
? Math.max(2, Math.min(Math.round(requestedCount), 512))
|
|
: 0;
|
|
}
|
|
|
|
function getPointerDistance(pointers) {
|
|
const values = [...pointers.values()];
|
|
if (values.length < 2) return 0;
|
|
return Math.hypot(values[0].x - values[1].x, values[0].y - values[1].y);
|
|
}
|
|
|
|
function getPointerCenter(pointers) {
|
|
const values = [...pointers.values()];
|
|
if (values.length < 2) return null;
|
|
return {
|
|
x: (values[0].x + values[1].x) / 2,
|
|
y: (values[0].y + values[1].y) / 2,
|
|
};
|
|
}
|
|
|
|
function panCamera(controls, dx, dy, canvas) {
|
|
const cameraDirection = new THREE.Vector3();
|
|
controls.camera.getWorldDirection(cameraDirection);
|
|
const right = new THREE.Vector3().crossVectors(cameraDirection, controls.camera.up).normalize();
|
|
const up = new THREE.Vector3().crossVectors(right, cameraDirection).normalize();
|
|
const speed = controls.radius / Math.max(canvas.clientWidth, canvas.clientHeight, 1);
|
|
controls.target.addScaledVector(right, -dx * speed);
|
|
controls.target.addScaledVector(up, dy * speed);
|
|
}
|
|
|
|
function resetCamera(preview, selectedView, fitPoints = [], axisReferenceMode = false) {
|
|
const preset = CAMERA_PRESETS[selectedView] || CAMERA_PRESETS.iso;
|
|
preview.controls.theta = preset.theta;
|
|
preview.controls.phi = preset.phi;
|
|
preview.controls.radius = preset.radius;
|
|
preview.controls.target.copy(preset.target);
|
|
preview.pathFitBoundsReady = axisReferenceMode
|
|
? applyAxisReferenceCamera(preview.controls)
|
|
: applyFitBounds(preview.controls, selectedView, fitPoints);
|
|
applyCameraControls(preview.controls);
|
|
}
|
|
|
|
function applyAxisReferenceCamera(controls) {
|
|
controls.theta = -0.48;
|
|
controls.phi = 0.82;
|
|
controls.radius = 0.125;
|
|
controls.target.set(0.001, -0.002, 0.006);
|
|
return true;
|
|
}
|
|
|
|
function applyFitBounds(controls, selectedView, fitPoints) {
|
|
const bounds = computePointBounds(fitPoints);
|
|
if (!bounds) return false;
|
|
const center = new THREE.Vector3();
|
|
const size = new THREE.Vector3();
|
|
bounds.getCenter(center);
|
|
bounds.getSize(size);
|
|
controls.target.copy(center);
|
|
const maxSpan = Math.max(size.x, size.y, size.z, 0.08);
|
|
const fitRadius = clamp(maxSpan * 1.8, 0.22, 4);
|
|
controls.radius = selectedView === "z" ? Math.max(fitRadius, 0.42) : fitRadius;
|
|
return true;
|
|
}
|
|
|
|
function applyCameraControls(controls) {
|
|
const sinPhiRadius = Math.sin(controls.phi) * controls.radius;
|
|
controls.camera.position.set(
|
|
controls.target.x + sinPhiRadius * Math.cos(controls.theta),
|
|
controls.target.y + sinPhiRadius * Math.sin(controls.theta),
|
|
controls.target.z + Math.cos(controls.phi) * controls.radius,
|
|
);
|
|
controls.camera.lookAt(controls.target);
|
|
controls.camera.updateProjectionMatrix();
|
|
}
|
|
|
|
function resizeRenderer(preview) {
|
|
const canvas = preview.renderer.domElement;
|
|
const width = Math.max(canvas.clientWidth, 320);
|
|
const height = Math.max(canvas.clientHeight, 240);
|
|
if (canvas.width !== width || canvas.height !== height) {
|
|
preview.renderer.setSize(width, height, false);
|
|
}
|
|
preview.camera.aspect = width / height;
|
|
preview.camera.updateProjectionMatrix();
|
|
}
|
|
|
|
function countSceneObjects(object) {
|
|
let count = 1;
|
|
for (const child of object.children) {
|
|
count += countSceneObjects(child);
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function toRoundedVector(vector) {
|
|
return {
|
|
x: round(vector.x),
|
|
y: round(vector.y),
|
|
z: round(vector.z),
|
|
};
|
|
}
|
|
|
|
function toRoundedPose(pose) {
|
|
return {
|
|
x: round(pose.x),
|
|
y: round(pose.y),
|
|
z: round(pose.z),
|
|
a: round(pose.a),
|
|
b: round(pose.b),
|
|
c: round(pose.c),
|
|
};
|
|
}
|
|
|
|
function round(value) {
|
|
return Math.round(Number(value) * 1000) / 1000;
|
|
}
|
|
|
|
function degreesToRadians(value) {
|
|
return (Number(value) || 0) * Math.PI / 180;
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|