Make machine configuration authoritative and restore native checkpoints exactly

This commit is contained in:
2026-09-18 09:09:41 +08:00
parent 6a69511276
commit 4a568f1f4c
94 changed files with 10882 additions and 301 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,7 @@
import { useEffect, useRef } from "react";
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
const VIEWER = WEB_SETTINGS.viewer;
const CAMERA = VIEWER.camera;
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
@@ -11,7 +14,7 @@ type Segment = { id?: number; active?: boolean; motion_type?: number; samples?:
type Props = { visual: XyzacVisualState; toolpath?: Segment[]; view?: "z" | "z2" | "x" | "y" | "y2" | "p"; zoom?: number; rotateMode?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean };
type Controller = Record<string, () => void>;
type Host = HTMLDivElement & { xyzacMachineController?: Controller };
const toView = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * .01, Number(point.z ?? 0) * .01, Number(point.y ?? 0) * .01);
const toView = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * VIEWER.mm_scale, Number(point.z ?? 0) * VIEWER.mm_scale, Number(point.y ?? 0) * VIEWER.mm_scale);
export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotateMode = false, showPath = false, showLive = false, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
const host = useRef<Host>(null);
@@ -23,7 +26,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
const element = host.current;
if (!element) return;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, VIEWER.pixel_ratio_limit));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
renderer.shadowMap.enabled = true;
@@ -31,7 +34,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
element.appendChild(renderer.domElement);
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x252b2c);
const scene = new THREE.Scene(); scene.background = new THREE.Color(VIEWER.colors.background);
const studio = new RoomEnvironment();
const pmrem = new THREE.PMREMGenerator(renderer);
const environment = pmrem.fromScene(studio, .04);
@@ -60,11 +63,11 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
const nameplate = new THREE.Mesh(new THREE.PlaneGeometry(.84, .21), new THREE.MeshBasicMaterial({ map: nameplateTexture }));
nameplate.position.set(-.57, -1.48, -1.046); nameplate.rotation.y = Math.PI; assembly.machine.add(nameplate);
const camera = new THREE.PerspectiveCamera(36, 1, .02, 150);
const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, CAMERA.near, CAMERA.far);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = .12;
controls.enableDamping = true; controls.dampingFactor = CAMERA.damping;
controls.enableRotate = true;
controls.screenSpacePanning = true; controls.minDistance = .25; controls.maxDistance = 65;
controls.screenSpacePanning = true; controls.minDistance = CAMERA.min_distance; controls.maxDistance = CAMERA.max_distance;
// Keep the standard Three.js mouse contract: left-drag orbits, middle
// drag zooms, right-drag pans, and the wheel zooms. The machine canvas
// must remain directly navigable without first toggling a mode button.
@@ -96,7 +99,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
const point = new THREE.Vector3(x, y, z).sub(modelCenter);
distance = Math.max(distance, point.dot(direction) + Math.max(Math.abs(point.dot(right)) / (tanV * camera.aspect), Math.abs(point.dot(up)) / tanV));
}
controls.target.copy(modelCenter); camera.position.copy(modelCenter).addScaledVector(direction, distance * 1.12 / Math.max(.5, scale));
controls.target.copy(modelCenter); camera.position.copy(modelCenter).addScaledVector(direction, distance * CAMERA.fit_margin / Math.max(.5, scale));
camera.lookAt(controls.target); controls.update();
};
const resize = () => {
@@ -106,7 +109,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
};
const observer = new ResizeObserver(resize); observer.observe(element); resize();
const pan = (x: number, y: number) => {
const amount = camera.position.distanceTo(controls.target) * .035;
const amount = camera.position.distanceTo(controls.target) * CAMERA.pan_fraction;
camera.updateMatrix();
const offset = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0).multiplyScalar(x * amount)
.addScaledVector(new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1), y * amount);
@@ -118,12 +121,12 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
camera.position.copy(controls.target).add(offset); controls.update();
};
element.xyzacMachineController = {
zoomIn: () => zoomCamera(.8), zoomOut: () => zoomCamera(1.25),
zoomIn: () => zoomCamera(CAMERA.zoom_in), zoomOut: () => zoomCamera(CAMERA.zoom_out),
panLeft: () => pan(-1, 0), panRight: () => pan(1, 0), panUp: () => pan(0, 1), panDown: () => pan(0, -1),
rotateLeft: () => { controls.rotateLeft(Math.PI / 12); controls.update(); },
rotateRight: () => { controls.rotateLeft(-Math.PI / 12); controls.update(); },
rotateUp: () => { controls.rotateUp(Math.PI / 12); controls.update(); },
rotateDown: () => { controls.rotateUp(-Math.PI / 12); controls.update(); },
rotateLeft: () => { controls.rotateLeft(THREE.MathUtils.degToRad(CAMERA.rotate_degrees)); controls.update(); },
rotateRight: () => { controls.rotateLeft(-THREE.MathUtils.degToRad(CAMERA.rotate_degrees)); controls.update(); },
rotateUp: () => { controls.rotateUp(THREE.MathUtils.degToRad(CAMERA.rotate_degrees)); controls.update(); },
rotateDown: () => { controls.rotateUp(-THREE.MathUtils.degToRad(CAMERA.rotate_degrees)); controls.update(); },
reset: () => { lastPreset = ""; },
tool: () => {
const target = assembly.toolTip.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0, .2, 0));
@@ -136,11 +139,11 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
// workpiece path together with the physical table.
const pathGroup = new THREE.Group(); pathGroup.name = "xyzac-toolpath"; assembly.joints.rotate.add(pathGroup);
const liveGroup = new THREE.Group(); liveGroup.name = "xyzac-live-toolpath"; assembly.joints.rotate.add(liveGroup);
const pathMaterials = [0xffff00, 0xff9a55, 0x5c9d9a].map((color) => new THREE.LineBasicMaterial({ color }));
const pathMaterials = [VIEWER.colors.path, VIEWER.colors.live, VIEWER.colors.rapid].map((color) => new THREE.LineBasicMaterial({ color }));
const clearLines = (group: THREE.Group) => { group.children.forEach((child) => (child as THREE.Line).geometry.dispose()); group.clear(); };
const limits = new THREE.Box3Helper(new THREE.Box3(), 0xdd7566); scene.add(limits);
const limits = new THREE.Box3Helper(new THREE.Box3(), VIEWER.colors.limits); scene.add(limits);
const offsets = new THREE.AxesHelper(.8); scene.add(offsets);
const extents = new THREE.Box3Helper(initialBounds, 0xe6c56b); scene.add(extents);
const extents = new THREE.Box3Helper(initialBounds, VIEWER.colors.extents); scene.add(extents);
const render = () => {
const state = visualRef.current, config = settings.current;
assembly.update(state);
@@ -165,7 +168,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
}
offsets.visible = config.showOffsets;
const coordinates = state.machineModel?.coordinate_system ?? "XYZAC";
offsets.position.set(coordinates === "XYZBC" ? Number(state.offsets.x_offset ?? 0) * .01 : 0, state.offsets.z_offset * .01, state.offsets.y_offset * .01);
offsets.position.set(coordinates === "XYZBC" ? Number(state.offsets.x_offset ?? 0) * VIEWER.mm_scale : 0, state.offsets.z_offset * VIEWER.mm_scale, state.offsets.y_offset * VIEWER.mm_scale);
extents.visible = config.showExtents;
const preset = `${config.view}:${config.zoom}`;
if (preset !== lastPreset) { presetCamera(); lastPreset = preset; }

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { projectXyzacVisual, type XyzacVisualSource, type XyzacVisualState } from "./xyzac-visual-adapter";
@@ -8,7 +9,7 @@ type Segment = { id?: number; source_line?: number | null; active?: boolean; kin
type Props = { visual: XyzacVisualState; view?: "z" | "z2" | "x" | "y" | "y2" | "p"; zoom?: number; rotateMode?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean };
const DEG = Math.PI / 180;
const MM = 0.01;
const MM = WEB_SETTINGS.viewer.mm_scale;
const sourcePoint = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * MM, Number(point.z ?? 0) * MM, Number(point.y ?? 0) * MM);
export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = false, showPath = true, showLive = true, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
@@ -24,11 +25,11 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const element = host.current;
if (!element) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050a0e);
scene.background = new THREE.Color(WEB_SETTINGS.viewer.toolpath_colors.background);
scene.add(new THREE.HemisphereLight(0xdcecff, 0x121820, 1.4));
let camera: THREE.PerspectiveCamera | THREE.OrthographicCamera = new THREE.PerspectiveCamera(40, 1, 0.001, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, WEB_SETTINGS.viewer.pixel_ratio_limit));
renderer.domElement.dataset.engine = "three.js r185 / xyzac-tool-preview";
element.appendChild(renderer.domElement);
let controls: OrbitControls<THREE.PerspectiveCamera | THREE.OrthographicCamera> = new OrbitControls(camera, renderer.domElement);
@@ -85,12 +86,12 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
}
group.clear();
};
const completed = new THREE.LineBasicMaterial({ color: 0xd8dfe3, depthTest: false });
const rapid = new THREE.LineDashedMaterial({ color: 0xa85d5d, dashSize: .025, gapSize: .018, depthTest: false });
const completed = new THREE.LineBasicMaterial({ color: WEB_SETTINGS.viewer.toolpath_colors.completed, depthTest: false });
const rapid = new THREE.LineDashedMaterial({ color: WEB_SETTINGS.viewer.toolpath_colors.rapid, dashSize: .025, gapSize: .018, depthTest: false });
// LinuxCNC uses yellow for jog backplot. Coordinated program execution is
// red (live plot), while an unambiguous current program block is cyan.
const live = new THREE.LineBasicMaterial({ color: 0x00ffff, depthTest: false });
const liveHistory = new THREE.LineBasicMaterial({ color: 0xff6b4a, depthTest: false });
const live = new THREE.LineBasicMaterial({ color: WEB_SETTINGS.viewer.toolpath_colors.active, depthTest: false });
const liveHistory = new THREE.LineBasicMaterial({ color: WEB_SETTINGS.viewer.toolpath_colors.live, depthTest: false });
let frame = 0;
let liveTick = -1;
let liveTrace: THREE.Vector3[] = [];

View File

@@ -1,4 +1,6 @@
declare module "*.mjs" {
export const WEB_SETTINGS: typeof import("../../workspace/web-settings.json");
export function axisImagePath(name: string, base?: string): string;
export const TRT_GEOMETRY_KEYS: readonly string[];
export function readTrtGeometry(configuration: { entrypoint: string; files: Record<string, string> }): { coordinates: string; offsets: Record<string, number>; sources: Record<string, { path: string; line: number; value: string }>; unsupported: string; state_hash: string };
export function writeTrtGeometry<T extends { entrypoint: string; files: Record<string, string> }>(configuration: T, values: Record<string, string | number>): T;

View File

@@ -1,6 +1,6 @@
// AXIS ships these exact 21/22/24px bitmap assets in the source tree.
export const LINUXCNC_AXIS_ASSETS = "/linuxcnc/share/axis/images";
import { axisImagePath, WEB_SETTINGS } from "../../workspace/web-settings.mjs";
export const LINUXCNC_AXIS_ASSETS = `${import.meta.env.BASE_URL}${WEB_SETTINGS.assets.axis_images}`;
export function axisIcon(name: string): string {
return `${LINUXCNC_AXIS_ASSETS}/${name}`;
return axisImagePath(name, import.meta.env.BASE_URL);
}

View File

@@ -82,7 +82,7 @@ async function execute(bundle, source) {
const beforeRecovery = session.xyzacKinematics.snapshot();
assert.equal(session.restoreCheckpoint(savedCheckpoint).status, "blocked");
assert.equal(session.restoreCompactPowerOff(savedCompact, seq + 1).status, "blocked");
assert.throws(() => session.importTrace(savedTrace), /trt_recovery_geometry_mismatch/);
assert.throws(() => session.importTrace(savedTrace), /trt_recovery_(geometry|configuration)_mismatch/);
assert.deepEqual(session.xyzacKinematics.snapshot(), beforeRecovery);
assert.equal(session.wasmHomeLimit.read().state.homed, false);
return { trace, offsets: expected, final: trace.at(-1), preview_hash: fnv1a64(preview.payload.preview), trace_hash: fnv1a64(trace), model_samples: samples.length };

View File

@@ -42,7 +42,7 @@ assert.equal(visual.source, "linuxcnc-wasm-worker");
assert.equal(visual.machineModel?.id, "xyzac-trt");
assert.equal(visual.machineModel?.source_hash, "fnv1a64:model");
assert.deepEqual(XYZAC_MACHINE_GROUPS, ["xyzac-machine", "table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "xyzac-toolpath", "xyzac-live-toolpath"]);
for (const token of ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "tableX.position.x = -tableXJoint * MM", "saddle.position.z = -saddleYJoint * MM", "zslide.position.y = (200 - spindleZJoint) * MM", "tilt.rotation.x = -tiltAJoint * DEG", "rotate.rotation.y = -rotateCJoint * DEG", "tool.position.y = -state.offsets.tool_offset * MM", "assembly.joints.rotate.add(pathGroup)", "assembly.joints.rotate.add(liveGroup)", "dataset.xyzacToolpathFrame", "dataset.xyzacToolpathAttached", "data-xyzac-tick", "data-xyzac-state-hash", "data-xyzac-active-segment", "data-xyzac-joints", "data-xyzac-offsets"]) assert.ok(source.includes(token), token);
for (const token of ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "tableX.position.x = -tableXJoint * MM", "saddle.position.z = -saddleYJoint * MM", "zslide.position.y = (datum - spindleZJoint) * MM", "tilt.rotation.x = -tiltAJoint * DEG", "rotate.rotation.y = -rotateCJoint * DEG", "tool.position.y = -state.offsets.tool_offset * MM", "assembly.joints.rotate.add(pathGroup)", "assembly.joints.rotate.add(liveGroup)", "dataset.xyzacToolpathFrame", "dataset.xyzacToolpathAttached", "data-xyzac-tick", "data-xyzac-state-hash", "data-xyzac-active-segment", "data-xyzac-joints", "data-xyzac-offsets"]) assert.ok(source.includes(token), token);
for (const forbidden of [/accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/, /HAL_SET/, /KINEMATICS_SELECT/]) assert.equal(forbidden.test(source), false, String(forbidden));
assert.ok(adapter.includes("source: \"linuxcnc-wasm-worker\""));
assert.equal(source.includes("axis-preview-hud xyzac-machine-hud"), false, "machine preview HUD must stay empty");

View File

@@ -2,27 +2,18 @@ import * as THREE from "three";
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
import type { XyzacVisualState } from "./xyzac-visual-adapter";
const MM = .01;
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
const MM = WEB_SETTINGS.viewer.mm_scale;
const DEG = Math.PI / 180;
// Source coordinates remain millimetres, Z up. The existing viewer uses Y up.
function buildTrtMachine(coordinates = "XYZAC") {
const isXyzbc = coordinates === "XYZBC";
const datum = WEB_SETTINGS.viewer.models[isXyzbc ? "XYZBC" : "XYZAC"].datum_z_mm;
// Separate source identities: XYZAC uses tilt-a, XYZBC uses tilt-b.
const rotaryAxis = isXyzbc ? "b" : "a";
const geometries = new Map<string, THREE.BufferGeometry>();
const materials = {
enamel: new THREE.MeshStandardMaterial({ color: 0xd9e1e1, roughness: .32, metalness: .25 }),
teal: new THREE.MeshStandardMaterial({ color: 0x167f79, roughness: .3, metalness: .3 }),
dark: new THREE.MeshStandardMaterial({ color: 0x303638, roughness: .42, metalness: .35 }),
steel: new THREE.MeshStandardMaterial({ color: 0xb2bdc4, roughness: .27, metalness: .85 }),
polished: new THREE.MeshStandardMaterial({ color: 0xe2e8ec, roughness: .19, metalness: .9 }),
recess: new THREE.MeshStandardMaterial({ color: 0x111718, roughness: .66, metalness: .2 }),
rubber: new THREE.MeshStandardMaterial({ color: 0x222729, roughness: .88 }),
carbide: new THREE.MeshStandardMaterial({ color: 0xb9aa79, roughness: .29, metalness: .8 }),
warning: new THREE.MeshStandardMaterial({ color: 0xe7bd48, roughness: .48, metalness: .12 }),
lamp: new THREE.MeshStandardMaterial({ color: 0xe2f8ff, emissive: 0xd4f2ff, emissiveIntensity: 1.5 }),
};
const materials = Object.fromEntries(Object.entries(WEB_SETTINGS.viewer.materials).map(([name, properties]) => [name, new THREE.MeshStandardMaterial(properties)])) as Record<keyof typeof WEB_SETTINGS.viewer.materials, THREE.MeshStandardMaterial>;
type Finish = keyof typeof materials;
const geometry = (key: string, create: () => THREE.BufferGeometry) => {
if (!geometries.has(key)) geometries.set(key, create());
@@ -84,7 +75,7 @@ function buildTrtMachine(coordinates = "XYZAC") {
box(machine, "z-servo", 0, -105, -169, 49, 21, 50, "dark", 3);
box(machine, "z-servo-cover", 0, -117, -169, 40, 3, 38, "teal", 2);
const head = group(machine, "head", 0, 0, isXyzbc ? 150 : 200);
const head = group(machine, "head", 0, 0, datum);
// XYZAC has the long moving knee bridge; XYZBC has the shorter moving
// spindle head from xyzbc-trt-gui.py.
const headDepth = isXyzbc ? 130 : 270;
@@ -175,8 +166,8 @@ function buildTrtMachine(coordinates = "XYZAC") {
}
for (let i = 0; i < 4; i++) {
const slot = box(rotate, "radial-t-slot", 0, 0, .14, 3, 42, .3, "recess", .1);
slot.position.x = Math.sin(i * Math.PI / 2) * .28;
slot.position.z = Math.cos(i * Math.PI / 2) * .28;
slot.position.x = Math.sin(i * Math.PI / 2) * (28 * MM);
slot.position.z = Math.cos(i * Math.PI / 2) * (28 * MM);
slot.rotation.y = i * Math.PI / 2;
}
for (let i = 0; i < 72; i++) {
@@ -197,18 +188,18 @@ function buildTrtMachine(coordinates = "XYZAC") {
tableX.position.x = -tableXJoint * MM;
saddle.position.z = -saddleYJoint * MM;
if (coordinates === "XYZBC") {
head.position.y = (150 + spindleZJoint) * MM;
head.position.y = (datum + spindleZJoint) * MM;
// XYZBC is a table tilt: the source model rotates the B assembly
// around machine Y. The Web scene maps source Y to Three.js Z.
head.rotation.set(0, 0, 0);
zslide.position.y = 150 * MM;
zslide.position.y = datum * MM;
tilt.rotation.z = -tiltAJoint * DEG;
tilt.position.set(xOffset * MM, zOffset * MM, 0).add(pivot);
brackets.position.copy(tilt.position);
rotaryBase.position.set(-xOffset * MM, -zOffset * MM, 0);
} else {
zslide.position.y = (200 - spindleZJoint) * MM;
head.position.y = 2;
zslide.position.y = (datum - spindleZJoint) * MM;
head.position.y = datum * MM;
head.rotation.set(0, 0, 0);
// XYZAC source transform: tilt.rotation.x = -tiltAJoint * DEG.
tilt.rotation.x = -tiltAJoint * DEG;
@@ -251,8 +242,8 @@ function createFlutedCutter() {
const angle = col / radial * Math.PI * 2;
const phase = (angle * 4 - t * Math.PI * 2 + Math.PI * 4) % (Math.PI * 2);
const relief = Math.pow(Math.max(0, Math.sin(phase / 2)), 2);
const radius = (3 - 1.35 * relief) * .01;
positions.push(radius * Math.cos(angle), t * .08, radius * Math.sin(angle));
const radius = (3 - 1.35 * relief) * MM;
positions.push(radius * Math.cos(angle), t * 8 * MM, radius * Math.sin(angle));
if (row < axial && col < radial) {
const a = row * (radial + 1) + col, b = a + radial + 1;
indices.push(a, b, a + 1, a + 1, b, b + 1);
@@ -260,7 +251,7 @@ function createFlutedCutter() {
}
}
const bottom = positions.length / 3; positions.push(0, 0, 0);
const top = positions.length / 3; positions.push(0, .08, 0);
const top = positions.length / 3; positions.push(0, 8 * MM, 0);
for (let col = 0; col < radial; col++) {
indices.push(bottom, col, col + 1);
const a = axial * (radial + 1) + col;