Add AC/BC RTCP programs and 3D machine manager

This commit is contained in:
2026-09-20 12:31:12 +08:00
parent 1c1e5f9672
commit 8aab531b02
363 changed files with 53213 additions and 58 deletions

View File

@@ -0,0 +1,17 @@
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const output = new URL('../docs/teaching/rtcp-five-axis/assets/', import.meta.url);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 2 });
await page.goto(process.env.XYZAC_PANEL_URL ?? 'http://127.0.0.1:5174/');
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
await page.addStyleTag({ content: '.xyzac-machine-controller { display: none !important; }' });
for (const id of ['ac', 'bc']) {
await page.locator('[data-axis-control="machine-glb-settings"]').click();
await page.locator(`[data-axis-control="machine-glb-${id}-gantry"]`).click();
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
await page.keyboard.press('Escape');
await page.locator('.xyzac-machine-view').screenshot({ path: new URL(`machine-${id}.png`, output).pathname });
}
} finally { await browser.close(); }

View File

@@ -0,0 +1,59 @@
"""Collect the already executed GLB subproject checks without rerunning them."""
import hashlib
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / 'coverage/glb-machine'
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
reports = [
'browser.report.json', 'motion.report.json', 'gltf-validation.report.json',
'reproducibility.report.json', 'regression-parametric/browser.report.json',
'regression-jog/rtcp-jog-browser.report.json',
]
results = []
for name in reports:
path = OUTPUT / name
data = json.loads(path.read_text())
assert data['status'] == 'verified', f'{name}: {data["status"]}'
results.append({'report_id': data['report_id'], 'path': str(path.relative_to(ROOT)), 'sha256': sha(path)})
browser = json.loads((OUTPUT / 'browser.report.json').read_text())
motion = json.loads((OUTPUT / 'motion.report.json').read_text())
manifest_path = ROOT / 'web/react/public/machines/manifest.json'
manifest = json.loads(manifest_path.read_text())
for entry in manifest['machines']:
assert sha(manifest_path.parent / entry['filename']) == entry['sha256']
assert 'built in' in (OUTPUT / 'build.log').read_text()
assert not browser['browser_errors']
assert len(browser['machines']) == len(motion['machines']) == 2
report = {
'report_id': 'glb-machine-acceptance-v1', 'status': 'verified',
'tasks': [f'GLB-{i:02}' for i in range(1, 7)],
'wasm_sha256': sha(ROOT / 'build-wasm/linuxcnc-sim-core.wasm'),
'reports': results,
'models': manifest['machines'],
'rejected_inputs': sum(len(p['rejected']) for p in browser['parser']),
'static_pose_and_tool_length_samples': sum(len(m['geometry']) for m in browser['machines']),
'imported_rotary_jogs': sum(len(m['jogs']) for m in browser['machines']),
'simultaneous_five_axis_intervals': sum(m['simultaneous_ticks'] for m in motion['machines']),
'executed_program_projection_samples': sum(m['samples'] for m in motion['machines']),
'max_program_projection_error_view_units': max(m['max_error_view_units'] for m in motion['machines']),
'boundaries': [
'Only the versioned orthogonal XYZ-head / AC-or-BC table TRT profile is supported',
'Runtime INI supplies homing, acceleration, periods and validated travel/speed settings',
'Imported GLB assets are kept in page memory; reload requires importing again',
'Original illustrative assets; no manufacturer calibration, collision or generic URDF solver claim',
'No native/WASM source modification; no whole-repository or release parity claim',
],
'evidence': [
{'path': str(p.relative_to(ROOT)), 'sha256': sha(p)}
for p in sorted(OUTPUT.rglob('*'))
if p.is_file() and p.name != 'acceptance.report.json'
],
}
assert report['wasm_sha256'] == browser['wasm_sha256']
(OUTPUT / 'acceptance.report.json').write_text(json.dumps(report, indent=2, ensure_ascii=False) + '\n')
print(json.dumps({k: report[k] for k in ['report_id', 'status', 'rejected_inputs', 'static_pose_and_tool_length_samples', 'imported_rotary_jogs', 'simultaneous_five_axis_intervals', 'executed_program_projection_samples']}, ensure_ascii=False))

View File

@@ -0,0 +1,140 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import * as THREE from '../web/react/node_modules/three/build/three.module.js';
import { GLTFExporter } from '../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js';
// GLTFExporter uses the browser FileReader interface for embedded binary buffers.
globalThis.FileReader = class {
readAsArrayBuffer(blob) { blob.arrayBuffer().then((result) => { this.result = result; this.onloadend?.(); }); }
readAsDataURL(blob) { blob.arrayBuffer().then((result) => { this.result = `data:${blob.type};base64,${Buffer.from(result).toString('base64')}`; this.onloadend?.(); }); }
};
const output = new URL('../web/react/public/machines/', import.meta.url);
await mkdir(output, { recursive: true });
const manifest = { schema: 'linuxcnc.machine.examples/1', generator: 'scripts/generate_glb_machines.mjs', provenance: 'Original procedural demonstration machines; no third-party mesh assets', license: 'CC0-1.0', machines: [] };
for (const variant of ['standard', 'gantry']) for (const type of ['ac', 'bc']) {
const gantry = variant === 'gantry';
const profile = gantry ? 'trt-xyz-gantry/1' : 'trt-xyz-head/1';
const toolLink = gantry ? 'z_slide' : 'spindle_head';
const stem = gantry ? `demo-${type}-gantry` : `demo-${type}-xyz-head`;
const tiltAxis = type[0].toUpperCase();
// Non-zero fixtures intentionally exercise rotation points and skewed cradle offsets.
const p = type === 'ac' ? [.024, -.012, .008] : [-.018, .014, .012];
const d = type === 'ac' ? [0, .018, -.045] : [.022, 0, -.040];
const t = p.map((v, i) => v + d[i]);
const radians = (degrees) => degrees * Math.PI / 180;
const joints = [
{ name: 'x_joint', parent: 'base', child: 'x_slide', type: 'prismatic', xyz: [0, 0, 0], axis: [1, 0, 0], c: 'X', limits: [-.2, .2, .02] },
{ name: 'y_joint', parent: 'x_slide', child: 'y_slide', type: 'prismatic', xyz: [0, 0, 0], axis: [0, 1, 0], c: 'Y', limits: [-.1, .1, .02] },
{ name: 'z_joint', parent: 'y_slide', child: 'z_slide', type: 'prismatic', xyz: [0, 0, 0], axis: [0, 0, 1], c: 'Z', limits: [-.12, .12, .02] },
{ name: 'spindle_mount', parent: 'z_slide', child: 'spindle_head', type: 'fixed', xyz: [0, 0, 0] },
{ name: 'tilt_joint', parent: 'base', child: 'tilt_table', type: 'revolute', xyz: t, axis: type === 'ac' ? [1, 0, 0] : [0, 1, 0], c: tiltAxis, limits: [radians(type === 'ac' ? -100 : -36000), radians(type === 'ac' ? 50 : 36000), radians(30)] },
{ name: 'c_joint', parent: 'tilt_table', child: 'c_table', type: 'revolute', xyz: d.map((v) => -v), axis: [0, 0, 1], c: 'C', limits: [radians(-36000), radians(36000), radians(30)] },
{ name: 'work_mount', parent: 'c_table', child: 'work_frame', type: 'fixed', xyz: p.map((v) => -v) },
];
if (gantry) joints.splice(joints.findIndex((j) => j.name === 'spindle_mount'), 1);
const links = ['base', ...joints.map((j) => j.child)];
const urdf = `<?xml version="1.0"?>\n<robot name="demo_${type}_${gantry ? 'gantry' : 'xyz_head'}" xmlns:cnc="urn:linuxcnc:web:machine:1">\n <cnc:machine profile="${profile}" base="base" tool="${toolLink}" work="work_frame"/>\n${links.map((n) => ` <link name="${n}"/>`).join('\n')}\n${joints.map((j) => ` <joint name="${j.name}" type="${j.type}">\n <parent link="${j.parent}"/><child link="${j.child}"/>\n <origin xyz="${j.xyz.join(' ')}" rpy="0 0 0"/>${j.c ? `\n <axis xyz="${j.axis.join(' ')}"/><cnc:axis coordinate="${j.c}"/>\n <limit lower="${j.limits[0]}" upper="${j.limits[1]}" velocity="${j.limits[2]}" effort="1000"/>` : ''}\n </joint>`).join('\n')}\n</robot>\n`;
const scene = new THREE.Scene(); scene.name = `CNC_${type.toUpperCase()}${gantry ? "_GANTRY" : ""}`;
scene.userData.linuxcncMachine = { schema: 'linuxcnc.machine/1', urdf };
scene.userData.assetLicense = 'CC0-1.0';
const frame = new THREE.Group(); frame.name = 'urdf_frame'; frame.rotation.x = -Math.PI / 2; scene.add(frame);
const nodes = Object.fromEntries(links.map((n) => { const g = new THREE.Group(); g.name = n; g.userData.link = n; return [n, g]; }));
frame.add(nodes.base);
for (const j of joints) { nodes[j.child].position.fromArray(j.xyz); nodes[j.parent].add(nodes[j.child]); }
const materials = Object.fromEntries(Object.entries({ enamel: [0xd8e4e3, .25, .32], teal: [0x167f83, .4, .27], dark: [0x253640, .35, .43], steel: [0x8a9faa, .85, .23], polished: [0xdce8ee, .9, .16], gold: [0xe4b34e, .7, .25], black: [0x122026, .2, .62] }).map(([n, [color, metalness, roughness]]) => [n, new THREE.MeshStandardMaterial({ color, metalness, roughness })]));
let counter = 0;
function mesh(parent, label, shape, position, material) { const m = new THREE.Mesh(shape, materials[material]); m.name = `${label}_${++counter}`; m.position.fromArray(position); m.castShadow = true; m.receiveShadow = true; parent.add(m); return m; }
const box = (parent, label, size, position, mat = 'enamel') => mesh(parent, label, new THREE.BoxGeometry(...size), position, mat);
const cyl = (parent, label, radius, length, position, mat = 'steel', axis = 'z') => { const m = mesh(parent, label, new THREE.CylinderGeometry(radius, radius, length, 48), position, mat); if (axis === 'z') m.rotation.x = Math.PI / 2; if (axis === 'x') m.rotation.z = Math.PI / 2; return m; };
const base = nodes.base;
box(base, 'bed', [.60, .52, .14], [0, .03, -.25]);
box(base, 'plinth', [.63, .55, .045], [0, .03, -.337], 'dark');
box(base, 'chip_tray', [.60, .46, .025], [0, -.015, -.169], 'steel');
for (const x of [-.275, .275]) box(base, 'tray_lip', [.018, .46, .04], [x, -.015, -.14], 'teal');
for (const x of [-.25, .25]) for (const y of [-.18, .22]) cyl(base, 'levelling_foot', .031, .025, [x, y, -.37], 'black');
if (gantry) {
// Four fixed posts carry X rails. The complete bridge belongs to X,
// its cross-carriage to Y, and the ram/spindle to Z.
box(base, 'gantry_foundation', [.88, .78, .045], [0, 0, -.355], 'dark');
for (const x of [-.38, .38]) for (const y of [-.32, .32]) {
box(base, 'gantry_post', [.055, .055, .82], [x, y, .05]);
box(base, 'post_foot', [.09, .09, .02], [x, y, -.35], 'teal');
}
const beam = (parent, label, start, end, thickness, mat) => {
const a = new THREE.Vector3(...start), b = new THREE.Vector3(...end);
const bar = box(parent, label, [thickness, thickness, a.distanceTo(b)], a.clone().add(b).multiplyScalar(.5).toArray(), mat);
bar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), b.sub(a).normalize());
};
for (const y of [-.32, .32]) {
box(base, 'gantry_longitudinal_beam', [.82, .06, .065], [0, y, .475], 'teal');
box(base, 'gantry_x_rail', [.78, .018, .012], [0, y, .517], 'polished');
for (let x = -.3; x < .31; x += .1) cyl(base, 'gantry_rail_bolt', .003, .004, [x, y, .526], 'black');
for (const sign of [-1, 1]) beam(base, 'corner_brace', [sign * .38, y, .30], [sign * .22, y, .45], .02, 'steel');
box(nodes.x_slide, 'gantry_x_bearing', [.12, .05, .025], [0, y, .54], 'dark');
}
for (const x of [-.38, .38]) {
box(base, 'fixed_end_tie', [.04, .69, .045], [x, 0, .46], 'steel');
}
for (const z of [.55, .65]) box(nodes.x_slide, 'moving_bridge_chord', [.06, .73, .035], [0, 0, z], 'teal');
for (let i = 0; i < 6; i++) {
const y = -.33 + i * .11;
beam(nodes.x_slide, 'bridge_diagonal', [0, y, i % 2 ? .65 : .55], [0, y + .11, i % 2 ? .55 : .65], .018, 'enamel');
}
for (const x of [-.043, .043]) box(nodes.x_slide, 'gantry_y_rail', [.014, .65, .016], [x, 0, .53], 'polished');
box(nodes.y_slide, 'gantry_y_carriage', [.13, .12, .035], [0, 0, .50], 'dark');
box(nodes.y_slide, 'gantry_ram_guide', [.145, .085, .14], [0, .036, .40], 'enamel');
for (const x of [-.045, .045]) box(nodes.z_slide, 'gantry_z_rail', [.012, .015, .29], [x, .065, .35], 'steel');
box(nodes.z_slide, 'gantry_z_ram', [.09, .07, .25], [0, .02, .365], 'teal');
} else {
for (const x of [-.245, .245]) {
box(base, 'portal_column', [.09, .12, .62], [x, .19, .11]);
box(base, 'column_inlay', [.095, .008, .48], [x, .126, .12], 'teal');
}
box(base, 'portal_crossbeam', [.58, .115, .10], [0, .19, .40]);
for (const z of [.365, .427]) {
box(base, 'x_rail', [.49, .013, .017], [0, .124, z], 'steel');
for (let x = -.22; x < .23; x += .055) cyl(base, 'rail_bolt', .003, .004, [x, .115, z], 'black', 'y');
}
box(nodes.x_slide, 'x_carriage', [.15, .035, .12], [0, .10, .394], 'teal');
for (const x of [-.045, .045]) box(nodes.x_slide, 'y_rail', [.014, .285, .016], [x, -.02, .365], 'steel');
box(nodes.y_slide, 'y_saddle', [.15, .13, .065], [0, 0, .327], 'dark');
for (const x of [-.055, .055]) box(nodes.y_slide, 'z_rail', [.014, .012, .23], [x, .055, .205], 'steel');
box(nodes.z_slide, 'z_carriage', [.135, .035, .18], [0, .055, .19], 'teal');
}
box(nodes[toolLink], 'head_casting', [.12, .13, .15], [0, -.018, .175]);
box(nodes[toolLink], 'head_face', [.108, .007, .10], [0, -.087, .185], 'teal');
cyl(nodes[toolLink], 'spindle_motor', .039, .095, [0, 0, .295], 'dark');
cyl(nodes[toolLink], 'spindle_housing', .037, .083, [0, 0, .088], 'steel');
cyl(nodes[toolLink], 'collet', .017, .03, [0, 0, .032], 'dark');
cyl(nodes[toolLink], 'master_tool', .004, .017, [0, 0, .0085], 'gold');
// AC bearings along X; BC bearings along Y. Both visual and joint axes agree.
for (const side of [-1, 1]) {
const pos = [...t]; pos[type === 'ac' ? 0 : 1] += side * .122;
const support = [...pos]; support[2] = -.111;
box(base, 'trunnion_pedestal', type === 'ac' ? [.055, .11, .116] : [.11, .055, .116], support);
cyl(base, 'tilt_bearing', .05, .06, pos, 'teal', type === 'ac' ? 'x' : 'y');
const shaft = [0, 0, 0]; shaft[type === 'ac' ? 0 : 1] = side * .084;
cyl(nodes.tilt_table, 'tilt_shaft', .022, .072, shaft, 'steel', type === 'ac' ? 'x' : 'y');
const arm = [...shaft]; arm[2] = -.025;
box(nodes.tilt_table, 'cradle_arm', type === 'ac' ? [.017, .10, .074] : [.10, .017, .074], arm, 'teal');
}
box(nodes.tilt_table, 'cradle', [.17, .16, .018], [0, 0, -.056], 'teal');
const rotaryHousing = new THREE.Group(); rotaryHousing.position.fromArray(d.map((v) => -v)); nodes.tilt_table.add(rotaryHousing);
cyl(rotaryHousing, 'c_drive', .075, .072, [0, 0, -.038], 'dark');
cyl(nodes.c_table, 'c_platter', .080, .019, [0, 0, -.0095], 'steel');
cyl(nodes.c_table, 'table_surface', .077, .001, [0, 0, .0005], 'polished');
for (let n = 0; n < 8; n++) {
const angle = n * Math.PI / 4;
cyl(nodes.c_table, 'fixture_hole', .003, .001, [.057 * Math.cos(angle), .057 * Math.sin(angle), .0012], 'black');
}
for (const y of [-.035, 0, .035]) box(nodes.c_table, 'table_t_slot', [.125, .003, .001], [0, y, .0015], 'black');
box(nodes.c_table, 'c_index', [.008, .014, .004], [.073, 0, .002], 'gold');
const glb = await new GLTFExporter().parseAsync(scene, { binary: true, trs: true, onlyVisible: false });
const filename = `${stem}.glb`;
await writeFile(new URL(filename, output), Buffer.from(glb));
await writeFile(new URL(`${stem}.urdf`, output), urdf);
manifest.machines.push({ id: gantry ? `${type}-gantry` : type, label: `${type.toUpperCase()} 示例${gantry ? '桁架型' : ''}`, profile, filename, urdf: `${stem}.urdf`, bytes: glb.byteLength, sha256: createHash('sha256').update(Buffer.from(glb)).digest('hex'), coordinates: `XYZ${tiltAxis}C`, rotation_point_mm: p.map((v) => v * 1000), offsets_mm: d.map((v) => v * 1000), links, meshes: counter });
scene.traverse((obj) => { obj.geometry?.dispose(); }); Object.values(materials).forEach((m) => m.dispose());
}
await writeFile(new URL('manifest.json', output), JSON.stringify(manifest, null, 2) + '\n');
await writeFile(new URL('LICENSE.txt', output), 'Original procedural AC/BC machine demonstration assets generated for linuxcncWebWASM.\nDedicated to the public domain under CC0 1.0 Universal.\nhttps://creativecommons.org/publicdomain/zero/1.0/\nNo manufacturer affiliation or dimensional calibration is implied.\n');
console.log(JSON.stringify(manifest, null, 2));

View File

@@ -0,0 +1,28 @@
import hashlib
import json
import shutil
from pathlib import Path
root = Path(__file__).resolve().parents[1]
output = root / 'coverage/glb-machine/jog-limits'
for name in ['worker', 'browser', 'checkpoint', 'motion', 'step', 'recovery', 'build', 'manual', 'buttons', 'manual-baseline', 'buttons-baseline', 'before']:
shutil.copyfile(Path('/tmp') / f'jog-limits-{name}.log', output / f'{name}.log')
for name in ['worker.report.json', 'browser.report.json', 'motion.report.json', 'exact-checkpoint.report.json', 'shadow.report.json']:
assert json.loads((output / name).read_text())['status'] == 'verified', name
assert 'built in' in (output / 'build.log').read_text()
report = {
'report_id': 'jog-limit-acceptance-v1', 'status': 'verified',
'tasks': ['TRT-JOG02', 'GLB-UI02'],
'wasm_sha256': hashlib.sha256((root / 'build-wasm/linuxcnc-sim-core.wasm').read_bytes()).hexdigest(),
'known_baseline_failures': [
{'test': 'test_xyzac_manual_control.mjs', 'failure': 'synchronous homing mask 0 != 1; identical with HEAD modules'},
{'test': 'test_xyzac_button_constraints.mjs', 'failure': 'unhomed versus machine-off gate priority; identical with HEAD modules'},
],
'boundaries': ['RTCP limit stops at last valid servo sample; no predictive hardware deceleration claim', 'Previously broken transport sessions require page reload'],
'evidence': [
{'path': str(path.relative_to(root)), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()}
for path in sorted(output.rglob('*')) if path.is_file() and path.name != 'acceptance.report.json'
],
}
(output / 'acceptance.report.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps({'report_id': report['report_id'], 'status': report['status'], 'evidence_count': len(report['evidence'])}))

View File

@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import createModule from '../build-wasm/linuxcnc-sim-core.js';
import { WasmProgramRuntime } from '../web/worker/wasm-program-runtime.mjs';
import { WasmXyzacKinematics } from '../web/worker/wasm-xyzac-kinematics.mjs';
import { SimSession, CONTEXT_ID } from '../web/worker/sim-session.mjs';
import { loadSimulationCase, toWorkerConfiguration } from '../web/workspace/sim-case-loader.mjs';
import { writeTrtGeometry } from '../web/workspace/trt-geometry.mjs';
import { MACHINE_DEFINITIONS } from '../web/workspace/machine-definitions.mjs';
const root = new URL('../', import.meta.url), out = new URL('docs/teaching/rtcp-beginners/', root);
await mkdir(new URL('assets/', out), { recursive: true });
await mkdir(new URL('verification/', out), { recursive: true });
const wasm = await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root));
const core = await createModule({ wasmBinary: wasm });
const limits = Object.fromEntries(['x','y','z','a','b','c'].map((a) => [a,[-100000,100000]]));
const k = new WasmXyzacKinematics(core, { coordinates: 'XYZBC', limits });
const interpreter = new WasmProgramRuntime(core);
interpreter.load('M2\n', 'beginner-reference.ngc');
const blocks = [
'N10 G21 G90 G94 G17',
'N20 G10 L2 P1 X40 Y20 Z10',
'N30 G54',
'N40 M428',
'N50 G1 X10 Y0 Z5 B0 C0 F600',
'N60 C90',
'N70 G91 X10',
'N80 G90 X20 Y0 Z5 B30 C90 F300',
'N90 X0 Y10',
'N100 M429',
'N110 M428',
];
const rows = [];
const axes = ['x','y','z','a','b','c','u','v','w'];
let joints = [0,0,0,0,0], mode = 0;
const poseOf = (state) => Object.fromEntries(axes.map((a) => [a, state[a] ?? 0]));
let canonical = poseOf({});
for (const block of blocks) {
const result = interpreter.executeBlock(block);
assert.equal(result.status, 'verified', JSON.stringify(result));
const code = result.program.control_code;
if ([428,429].includes(code)) {
mode = code === 428 ? 1 : 0;
const selected = k.selectPhysical(mode, joints);
assert.equal(selected.status, 'verified');
interpreter.synchronizePosition(selected.pose);
canonical = { ...selected.pose };
} else if (result.motion) {
const inverse = k.inverseMode(mode, result.motion.end);
assert.equal(inverse.status, 'verified'); joints = inverse.joints;
canonical = { ...result.motion.end };
}
const state = interpreter.snapshot(), origin = state.coordinate_context.offsets[state.coordinate_context.system-1];
rows.push({ block, has_motion: Boolean(result.motion), mode, canonical: { ...canonical }, work_xyz: ['x','y','z'].map((a,i)=>canonical[a]-origin[i]), joints: [...joints], origin: origin.slice(0,3), units: state.units, distance_mode: state.distance_mode, feed: state.feed, motion: result.motion });
}
const near = (actual, expected) => actual.forEach((v,i) => assert.ok(Math.abs(v-expected[i])<1e-8, `${v} != ${expected[i]}`));
near(rows[4].joints,[50,20,15,0,0]);
near(rows[5].joints,[-20,50,15,0,90]);
near(rows[6].joints,[-20,60,15,0,90]);
near(rows[7].joints,[-9.820508075688775,60,22.99038105676658,30,90]);
near(rows[9].joints,rows[8].joints); near(rows[10].joints,rows[8].joints);
function probe(blocks) {
const p = new WasmProgramRuntime(core); p.load('M2\n','probe.ngc');
let canonical = poseOf({});
const rows = blocks.map((block) => {
const result=p.executeBlock(block); assert.equal(result.status,'verified',block);
const state=p.snapshot();
if (result.motion) canonical = { ...result.motion.end };
return { block, canonical: { ...canonical }, units:state.units,distance_mode:state.distance_mode,feed:state.feed, coordinates:state.coordinate_context,motion:result.motion };
});
p.destroy(); return rows;
}
const units = probe(['G21 G90 G1 X0 Y0 Z0 F600','G20 G91 X1 F2','G21 G90 X30']);
near([units[1].canonical.x,units[1].feed],[25.4,50.8]);
const touch = probe(['G21 G90 G1 X70 Y30 Z20 F600','G10 L20 P1 X0 Y0 Z0','G54 X10']);
near(touch[1].coordinates.offsets[0].slice(0,3),[70,30,20]); near([touch[2].canonical.x],[80]);
const switching = probe(['G21 G90 G10 L2 P1 X40 Y20 Z10','G10 L2 P2 X80 Y-10 Z10','G54 G1 X10 Y0 Z5 F600','G55','X10']);
near([switching[2].canonical.x,switching[3].canonical.x,switching[4].canonical.x],[50,50,90]);
interpreter.destroy(); k.destroy();
const fetchImpl = async (url) => { try { const text=await readFile(new URL(String(url).replace(/^\//,''),root),'utf8'); return {ok:true,text:async()=>text}; } catch {return {ok:false};} };
const definition=MACHINE_DEFINITIONS.find((m)=>m.coordinates==='XYZBC');
const sessionCore=await createModule({wasmBinary:wasm});
const session=new SimSession(sessionCore,{sessionId:'rtcp-beginner-teaching',planner:'linuxcnc-tp'});let seq=0;
const send=(kind,payload={})=>{
const response=session.accept({schema_version:1,context_id:CONTEXT_ID,session_id:session.sessionId,kind,seq:++seq,operation_id:`lesson-${seq}`,payload});
assert.notEqual(response.kind,'ERROR',JSON.stringify(response.payload));return response;
};
send('HELLO');send('SESSION_ATTACH');
const bundle=writeTrtGeometry(await loadSimulationCase({path:definition.entrypoint,fetchImpl}),{x_rot_point:0,y_rot_point:0,z_rot_point:0,x_offset:0,y_offset:0,z_offset:0});
send('LOAD_WORKSPACE',{root_path:'/workspace',configuration:toWorkerConfiguration(bundle)});
const program=blocks.slice(0,9).join('\n')+'\nM2\n';
send('PROGRAM_LOAD',{source:program,path:'beginner-g54-rtcp.ngc'});send('POWER',{active:true});send('HOME',{all:true});
for(let i=0;i<500&&!session.wasmHomeLimit.read().state.homed;i++)send('SIM_STEP',{cycles:1});
assert.ok(session.wasmHomeLimit.read().state.homed);send('PROGRAM_RUN');
let final;const trajectory=[];
for(let i=0;i<6000;i++){
final=send('SIM_STEP',{cycles:5,response:'motion_delta'});
const state=session.wasmMotion.read().state;
if(i%10===0)trajectory.push({tick:session.tick,pose:state.position,joints:state.joint_commanded});
if(final.payload.program?.program_status==='completed'&&state.queue_depth===0)break;
}
assert.equal(final.payload.program.program_status,'completed');
const end=session.wasmMotion.read().state;
near(end.joint_commanded,rows[8].joints);
const report={report_id:'rtcp-beginners-gcode-v1',status:'verified',wasm_sha256:createHash('sha256').update(wasm).digest('hex'),assumptions:{coordinates:'XYZBC',rot_point:[0,0,0],offsets:[0,0,0],tool_offset:0,work_origin:[40,20,10],rotation:'Ry(B)*Rz(C)',work_rotation:0},rows,units,touch,switching,executed_program:{source:program,status:final.payload.program.program_status,final_pose:end.position,final_joints:end.joint_commanded,samples:trajectory}};
await writeFile(new URL('gcode-evidence.json',out),JSON.stringify(report,null,2)+'\n');
console.log(JSON.stringify({report_id:report.report_id,status:report.status,blocks:rows.length,extra_blocks:units.length+touch.length+switching.length,trajectory_samples:trajectory.length,final:end.joint_commanded}));

View File

@@ -0,0 +1,103 @@
"""Original diagrams for the beginner's work-coordinate handbook."""
import shutil
from pathlib import Path
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib import font_manager
from matplotlib.patches import Rectangle, Polygon
import numpy as np
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT/'docs/teaching/rtcp-beginners/assets';OUT.mkdir(parents=True,exist_ok=True)
font_manager.fontManager.addfont('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc')
plt.rcParams.update({'font.family':'Noto Sans CJK JP','font.size':14,'axes.unicode_minus':False})
BLUE,TEAL,RED,GRAY,INK='#2563b8','#00877b','#d45549','#8a969f','#23303a'
for name in ['machine-ac','machine-bc','hierarchy','identity-no-follow','tcp-follow','rotated-direction','bc-frame','pivot-offset','arc-chord','tool-length','orientation']:
shutil.copyfile(ROOT/f'docs/teaching/rtcp-five-axis/assets/{name}.png',OUT/f'{name}.png')
def save(fig,name):
fig.savefig(OUT/f'{name}.png',dpi=190,facecolor='white',bbox_inches='tight',pad_inches=.18);plt.close(fig)
def arrow(ax,start,end,color,label='',dx=0,dy=0):
ax.annotate('',end,start,arrowprops={'arrowstyle':'-|>','lw':2,'color':color})
if label:ax.text(end[0]+dx,end[1]+dy,label,color=color,fontsize=14)
fig,ax=plt.subplots(figsize=(8,4.2));ax.set(xlim=(-8,55),ylim=(-8,38),aspect='equal');ax.axis('off')
for x in range(0,51,10):ax.plot([x,x],[0,30],c='#dce6ec',lw=1);ax.text(x,-4,str(x),ha='center',fontsize=12,color=GRAY)
for y in range(0,31,10):ax.plot([0,50],[y,y],c='#dce6ec',lw=1);ax.text(-4,y,str(y),va='center',fontsize=12,color=GRAY)
arrow(ax,(0,0),(54,0),BLUE,'X',-1,-5);arrow(ax,(0,0),(0,36),BLUE,'Y',-5,-1)
ax.scatter([30],[20],s=90,color=RED);ax.plot([30,30,0],[0,20,20],ls='--',color=RED,lw=1.5)
ax.text(32,22,'P = (30, 20) mm',color=RED);ax.text(2,32,'先选原点,再沿指定方向量距离',fontsize=14,color=INK)
save(fig,'coordinate-grid')
fig,ax=plt.subplots(figsize=(8,4.4));ax.set(xlim=(-10,95),ylim=(-10,60),aspect='equal');ax.axis('off')
arrow(ax,(0,0),(90,0),BLUE,'X_M',-8,-7);arrow(ax,(0,0),(0,56),BLUE,'Y_M',-8,-2)
arrow(ax,(40,20),(79,20),TEAL,'X_W',0,-2);arrow(ax,(40,20),(40,54),TEAL,'Y_W',2,0)
ax.scatter([50],[35],s=95,color=RED);ax.text(54,38,'同一个点 P',color=RED)
ax.plot([50,50,40],[20,35,35],ls='--',color=TEAL);ax.text(43,24,'10',color=TEAL);ax.text(32,27,'15',color=TEAL)
ax.annotate('O_W 在 M 中为 (40,20)',(40,20),xytext=(23,6),fontsize=12,arrowprops={'arrowstyle':'->','color':GRAY})
ax.text(3,46,'P_M=(50,35)\nP_W=(10,15)',fontsize=15,color=INK)
save(fig,'same-point')
fig,axes=plt.subplots(1,2,figsize=(10,5))
for ax,c in zip(axes,[0,90]):
ax.set(xlim=(-55,85),ylim=(-28,95),aspect='equal');ax.axis('off')
arrow(ax,(0,0),(78,0),BLUE,'X_M',-5,-12);arrow(ax,(0,0),(0,85),BLUE,'Y_M',-18,-1)
rad=np.deg2rad(c);r=np.array([[np.cos(rad),-np.sin(rad)],[np.sin(rad),np.cos(rad)]])
o=r@np.array([40,20]);point=r@np.array([50,20]);x=r@np.array([25,0]);y=r@np.array([0,25])
poly=np.array([o+r@np.array(v) for v in [[-5,-5],[30,-5],[30,20],[-5,20]]]);ax.add_patch(Polygon(poly,fc='#e1f2ee',ec=TEAL,alpha=.65))
arrow(ax,o,o+x,TEAL,'X_W',2,0);arrow(ax,o,o+y,TEAL,'Y_W',2,0)
ax.scatter(*o,color=TEAL,s=70);ax.scatter(*point,color=RED,s=100,marker='*')
ax.text(-48,89,f'C = {c}°',color=INK,fontsize=17)
ax.text(-48,-23,f'O_M=({o[0]:.0f},{o[1]:.0f})\nP_M=({point[0]:.0f},{point[1]:.0f})',color=INK,fontsize=13)
label_position=(12,57) if c else (-45,67)
ax.annotate('P:X10,Y0',point,xytext=label_position,arrowprops={'arrowstyle':'->','color':RED},color=RED,fontsize=12)
fig.subplots_adjust(wspace=.15)
save(fig,'moving-origin')
fig,ax=plt.subplots(figsize=(8,4.5));ax.axis('off');ax.set(xlim=(-10,120),ylim=(-15,80),aspect='equal')
front=np.array([[10,15],[85,15],[85,38],[10,38]]);side=np.array([[85,15],[108,37],[108,60],[85,38]]);top=np.array([[10,38],[85,38],[108,60],[33,60]])
for poly,color in [(front,'#d4e3e8'),(side,'#b9cdd3'),(top,'#e5f2ef')]:ax.add_patch(Polygon(poly,fc=color,ec=GRAY))
origin=(10,38);arrow(ax,origin,(66,38),TEAL,'+X',0,-7);arrow(ax,origin,(33,60),TEAL,'+Y',-4,7);arrow(ax,origin,(10,72),TEAL,'+Z',2,0)
ax.scatter(*origin,color=RED,s=70);ax.annotate('示例:顶面左前角为零点',origin,xytext=(5,2),arrowprops={'arrowstyle':'->','color':RED},color=RED,fontsize=14)
ax.scatter([65],[48],color=BLUE,s=130,facecolors='none');ax.annotate('也可选孔中心\n必须与CAD/CAM约定一致',(65,48),xytext=(75,74),arrowprops={'arrowstyle':'->','color':BLUE},color=BLUE,fontsize=12)
save(fig,'datum-choice')
fig,ax=plt.subplots(figsize=(8,4.5));ax.axis('off');ax.set(xlim=(-20,110),ylim=(-20,85),aspect='equal')
o=np.array([15,8]);a=np.deg2rad(20);u=np.array([np.cos(a),np.sin(a)]);v=np.array([-np.sin(a),np.cos(a)])
pts=[o,o+u*75,o+v*45]
ax.plot([pts[1][0],o[0],pts[2][0]],[pts[1][1],o[1],pts[2][1]],color=TEAL,lw=2)
for p,label,dx,dy in zip(pts,['O:所选原点','P_x:确定 +X','P_y:确定 XY 面'],[-12,2,-10],[-12,0,6]):ax.scatter(*p,s=80,color=RED);ax.text(p[0]+dx,p[1]+dy,label,fontsize=13,color=INK)
arrow(ax,o,o+u*93,TEAL,'+X',0,0);arrow(ax,o,o+v*62,TEAL,'+Y',-5,3)
arrow(ax,(-8,-9),(100,-9),BLUE,'机床 X',-25,-8)
ax.text(48,64,'工件不能只靠“一个点”\n确定全部方向',color=INK,fontsize=15,ha='center')
save(fig,'orientation-measure')
fig,axes=plt.subplots(2,1,figsize=(8,3.6))
for ax,title,target in zip(axes,['当前位置 X=10,G90 X20','当前位置 X=10,G91 X20'],[20,30]):
ax.set(xlim=(-3,39),ylim=(-1,6));ax.axis('off');arrow(ax,(0,1),(37,1),BLUE)
for x in [0,10,20,30]:ax.plot([x,x],[.8,1.2],color=GRAY);ax.text(x,-.2,str(x),ha='center',fontsize=13)
ax.text(0,4,title,fontsize=15,color=INK);ax.scatter([10],[1],color=GRAY,s=70);ax.scatter([target],[1],color=RED,s=90)
arrow(ax,(10,2),(target,2),RED);ax.text((10+target)/2,2.8,f'实际移动 {target-10}',ha='center',color=RED,fontsize=12)
fig.subplots_adjust(hspace=.35);save(fig,'absolute-incremental')
fig,ax=plt.subplots(figsize=(8,4));ax.axis('off');ax.set(xlim=(0,10),ylim=(0,5))
rows=[('① 程序工件点 W','(10, 0, 5)',TEAL),('② 加 G54 零位偏置','+(40, 20, 10)',GRAY),('③ 运动学输入 K','(50, 20, 15)',BLUE),('④ C=90°,RTCP变换','(-20, 50, 15)',RED)]
for i,(label,value,color) in enumerate(rows):
y=4.15-i*1.06;ax.add_patch(Rectangle((.3,y-.34),9.3,.74,fc='#eef4f6',ec='none'))
ax.text(.55,y,label,color=color,fontsize=15,va='center');ax.text(6.1,y,value,color=color,fontsize=16,va='center')
if i<3:ax.text(5,y-.58,'↓',ha='center',va='center',color=GRAY)
save(fig,'offset-pipeline')
c=np.linspace(0,90,181);r=np.deg2rad(c);qx=50*np.cos(r)-20*np.sin(r);qy=50*np.sin(r)+20*np.cos(r)
fig,ax=plt.subplots(figsize=(8,4));ax.plot(c,qx,color=BLUE,lw=2.4,label='关节 X');ax.plot(c,qy,color=TEAL,lw=2.4,label='关节 Y');ax.plot(c,np.full_like(c,15),color=RED,lw=2.4,label='关节 Z')
ax.set(xlabel='C 角度(°)',ylabel='关节位置(mm)',xlim=(0,90));ax.grid(alpha=.2);ax.legend(loc='lower left',ncol=3,fontsize=12);ax.set_title('程序 X10 Y0 Z5 保持;关节由RTCP连续计算',fontsize=15,color=INK)
fig.tight_layout();save(fig,'continuous-joints')
fig,ax=plt.subplots(figsize=(6.7,4.1));ax.set(aspect='equal',xlim=(-3,14),ylim=(-3,14));ax.axis('off')
arrow(ax,(0,0),(13,0),BLUE,'X',0,-1);arrow(ax,(0,0),(0,13),BLUE,'Y',-1,0)
t=np.linspace(0,np.pi/2,100);ax.plot(10*np.cos(t),10*np.sin(t),color=TEAL,lw=3)
arrow(ax,(8.5,5.27),(7.8,6.25),TEAL);ax.scatter([10,0,0],[0,10,0],color=[RED,TEAL,GRAY],s=80)
ax.text(6.8,-1.8,'起点 (10,0)',color=RED,fontsize=13);ax.text(.8,10.5,'终点 (0,10)',color=TEAL,fontsize=13)
ax.text(2.8,3.5,'圆心 (0,0)\nI=-10,J=0',color=INK,fontsize=14)
ax.plot([10,0],[0,0],ls=':',color=GRAY,lw=2);save(fig,'arc-words')
print('Created 9 new diagrams and reused 11 verified original assets:',OUT)

View File

@@ -0,0 +1,621 @@
"""Self-contained A4 handbook: beginner-first explanations and verified examples."""
import html
import json
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT/'docs/teaching/rtcp-beginners'
DATA=json.loads((OUT/'gcode-evidence.json').read_text())
PAGES=[]
def p(text,cls=''):return f'<p class="{cls}">{text}</p>'
def h(text):return f'<h2>{text}</h2>'
def ul(*items):return '<ul>'+''.join(f'<li>{x}</li>' for x in items)+'</ul>'
def ol(*items):return '<ol>'+''.join(f'<li>{x}</li>' for x in items)+'</ol>'
def box(title,text):return f'<aside><b>{title}</b>{p(text)}</aside>'
def eq(text):return f'<div class="equation">{text}</div>'
def code(text):return '<pre>'+html.escape(text)+'</pre>'
def fig(name,caption,height=260):return f'<figure><img src="assets/{name}.png" style="height:{height}px" alt="{html.escape(caption)}"><figcaption>{caption}</figcaption></figure>'
def table(heads,rows,cls=''):return '<table class="'+cls+'"><thead><tr>'+''.join('<th>'+x+'</th>' for x in heads)+'</tr></thead><tbody>'+''.join('<tr>'+''.join('<td>'+str(x)+'</td>' for x in row)+'</tr>' for row in rows)+'</tbody></table>'
def page(chapter,title,body,key,refs='原创讲解;以本讲义所声明的双转台结构为限。'):
PAGES.append({'chapter':chapter,'title':title,'body':body,'key':key,'refs':refs})
def xyz(values):return '('+', '.join(f'{0 if abs(v)<.0005 else v:.3f}' for v in values[:3])+')'
page('封面 · 完整讲义 / 2026.09','五轴 RTCP 与工件坐标',
p('从坐标零点到 G 代码,再到每根轴的运动','subtitle')+
p('面向初学者的完整学习路径:先认识“相对谁量位置”,再理解“角度改变后,控制器如何保持刀尖相对工件的目标关系”。')+
fig('machine-bc','本项目原创 XYZBC 桁架模型:刀具侧 XYZ,工件侧 B→C。',340)+
box('你将学会回答','工件原点放在哪里?X/Y/Z方向由谁确定?G54有没有让坐标跟着转台转?为什么一个X10有时会让多根轴运动?为什么只改C角度也会引起XYZ联动?')+
p('包含:基础概念、图解、逐字解析、逐行数值演算、实验步骤、排错方法、练习和答案。<br>建议分 4–6 次授课,也可按目录自学。','small'),
'读完后,应能把“程序写的数值”和“电机实际要到的位置”分开解释。')
page('导读','怎样使用这份讲义',
p('不要求你先会矩阵或五轴编程。先把坐标看成贴在物体上的尺子,再把RTCP看成依据机床结构换算位置的计算过程。公式附录可以最后再读。')+
h('三种标签,避免把不同层次混在一起')+
table(['层次','在讲义中怎样使用'],[
['通用概念','坐标、相对位置、程序段、绝对/增量、物理关节等基础知识。'],
['本课模型','XYZ移动刀具;A/C或B/C转动工件;按本项目正方向和父子层级计算。'],
['已实测算例','通过本项目WASM解释器、运动学和规划器验证的数值;不是手工猜测。'],
])+
h('推荐阅读顺序')+ol('第1–3章:先弄清坐标系、原点、方向及工件偏置。','第4–6章:理解RTCP,再学习G代码各地址字与模式。','第7章:跟着一份完整程序逐行计算,自己复算。','第8–10章:操作实验、排错、练习;最后查数学附录。')+
p('本项目仿真只覆盖LinuxCNC的一部分能力。讲到G52/G92、坐标旋转、完整刀补等功能时,会区分官方控制器语义和项目已验证的范围。','small'),
'不要把某个控制器或示例的约定,当作所有机床都必须遵循的规则。')
page('学习地图','内容索引',
'<div id="toc-placeholder"></div>',
'先学概念,后读代码;遇到陌生词可跳到附录术语表。')
page('第1章 · 坐标基础','01 坐标是“按照约定量出来的位置”',
p('想象一张方格纸。先指定一个交点为原点O,再规定向右是+X、向上是+Y,每格代表10mm。这样你就能用两个数告诉别人某个孔的位置,而不必每次都画整张图。')+
fig('coordinate-grid','P=(30,20)mm:从原点沿+X量30mm,再沿+Y量20mm。',280)+
p('这里的30和20不是电机编号,也不是速度。它们是点P相对于这套尺子的两个坐标分量。换一个原点,或者把尺子的方向转动,同一个点的坐标数字就会改变。')+
box('最重要的习惯','说“X等于30”之前,先补一句:相对于哪一个坐标系?单位是什么?如果这两点没说明,数字还不能唯一描述空间位置。'),
'坐标数字必须和“原点、方向、单位”一起理解。')
page('第1章 · 坐标基础','02 一套坐标系至少要规定什么?',
table(['要素','初学者理解','不说明会怎样'],[
['原点','从哪里开始量。','相同数值落在不同位置。'],['方向','X、Y、Z分别朝哪边。','相同增量可能走向不同方向。'],['单位','例如长度mm、旋转角度°。','X1可能是1mm,也可能是25.4mm。'],['正负号与手性','三轴之间的正向约定。','模型可能镜像、旋转方向可能相反。'],
])+
p('三维直角坐标系有三条互相垂直的轴。一般按右手规则建立:+X和+Y确定后,+Z的方向也随之确定。不能任意把其中一根轴反向,同时还声称保持同一套右手坐标。')+
p('坐标系在图上常画成三根箭头;在控制器中,它由参数和变换关系表示。屏幕上的箭头只是它的可视化,拖动摄像机不会改变机床的真实坐标约定。')+
box('单位提醒','机床运行示例使用mm和度;GLB/URDF采用米与弧度。1m=1000mm,180°=πrad。软件应显式转换,不能只把数值原样传过去。'),
'三个方向不是三条新导轨,而是描述位置的三个基准方向。')
page('第1章 · 坐标基础','03 同一个点,可以有两套不同的坐标',
fig('same-point','工件原点在机床中为(40,20)。同一P点:机床坐标(50,35),工件坐标(10,15)。',290)+
p('假设两套坐标的方向相同,只是原点平移。绿色工件原点比蓝色机床原点多了X40、Y20。于是工件上的P=(10,15),在机床坐标中就是(50,35)。')+
eq('机床中的位置 = 工件原点的位置 + 工件内的坐标')+
p('反过来,若已知机床中的点位置,需要减去工件原点,才能得到工件内的读数。这就是最简单的“工件偏置”直觉。')+
box('注意适用条件','这一页只有平移,方向没有转动,也没有刀长补偿。后面工件发生旋转时,还必须先把工件方向换算到机床方向,不能始终只做三个数相加。'),
'点没有动,坐标数字也可以变;改变的是量它的尺子。')
page('第1章 · 坐标基础','04 点坐标与移动增量不是一回事',
p('“去X20”描述一个目的地;“向+X再走20”描述一段位移。当前位置不同,这两句话会产生不同结果。')+
fig('absolute-incremental','当前位置X=10:绝对目标X20移动10;增量X20移动20,终点到X30。',250)+
p('点坐标需要一个原点。位移向量描述从一个点到另一个点的差,原点平移不会改变这段差值;但是坐标方向转动,会改变它在另一套坐标中的分量。')+
table(['写法','意思'],[['p=(10,5,2)','一个目标点的位置。'],['Δp=(10,0,0)','沿当前编程+X方向增加10。'],['X轴方向向量','只说明方向;不能直接当作某个目标点。']])+
p('后面会反复用到:p表示点,Δp表示增量。不要因为都含X/Y/Z三个数就把它们混用。'),
'“目标在哪里”和“这一步走多远”是两个问题。')
page('第1章 · 机床结构','05 五轴机床里,到底是哪五根轴?',
p('本课选择双转台桁架结构:三根直线轴X、Y、Z让刀具移动;两根旋转轴让工件转动。AC型使用A和C,BC型使用B和C。它们不是XYZABC六根独立轴都同时存在。')+
table(['坐标字母','典型含义','本课执行部件'],[['X/Y/Z','直线位置','横梁、滑座、滑枕/刀具'],['A','绕X方向的轴线转动','AC外层倾转台'],['B','绕Y方向的轴线转动','BC外层倾转台'],['C','绕本地Z方向轴线转动','安装在倾转台上的内层转台']])+
fig('hierarchy','两条独立分支:刀具侧XYZ;工件侧A/B→C。',220)+
p('C的轴线装在A/B上,所以A/B倾转会连C的轴线一起带走。这不是软件的小细节,而是决定运动学计算次序的机械事实。','small'),
'先查结构图,确认谁安装在谁上面,再讨论坐标如何变换。','[S1][S3];本项目桁架模型。')
page('第1章 · 机床结构','06 关节位置,不等于工件坐标',
p('把“关节”理解为一个执行机构及它的位置。在本项目里,J0/J1/J2对应XYZ直线轴,J3是A或B,J4是C。每个关节位置属于机构自身的运动参数。')+
table(['对象','回答什么问题'],[['程序点','刀尖相对于选定编程参考系要到哪里?'],['关节指令','各执行轴应该到哪里?'],['实际反馈','编码器报告机械轴实际上在哪里?'],['三维模型刀尖','将关节位置代入结构模型后,画面上的刀尖在哪里?']])+
p('简单三轴、没有偏置时,这几类数字可能很相近,容易造成“它们本来就是同一回事”的错觉。五轴RTCP会把这种差别明显表现出来:程序XYZ不变,关节XYZ却需要变化。')+
box('理想仿真与真实机床','本项目通常把关节指令和理想反馈看成相同。真实机床还存在跟随误差、伺服滞后、机械误差和测量误差,不能仅凭指令就断言真实刀尖已精确到位。'),
'程序描述加工目标;关节是执行这个目标的机构参数。')
page('第2章 · 参考系','07 先认识 M、W、J,再引入 K 和 T',
table(['符号','在本讲义中的含义','在哪里看'],[['M','固连机床底座的物理笛卡尔参考系。','观察真实空间位置与方向。'],['W','设计给零件编程用的工件坐标系。','图纸、CAD/CAM、G54编程。'],['J / q','各机械关节的位置集合。','J0…J4、编码器/关节显示。'],['K','已经解释偏置、尚未经过RTCP逆解的坐标输入。','本项目解释器输出到运动学的接口。'],['T','刀具参考系或刀具受控点的定义。','主轴参考点、刀长标定、TCP。']])+
p('初学者先记住M、W、J三个概念。K是在复杂机床中避免混淆的“中间层”:控制器界面叫作Machine的XYZ,有时是运动学坐标,不能不看定义就认定它一定等于物理直线关节。')+
p('本讲义用K做教学记号,不是要求控制器界面必须新增一个叫K的按钮。它也不是G代码中的圆弧中心地址K;同一个字母处在不同上下文中,意义不同。')+
box('读图颜色','蓝色:机床/基础参考;绿色:工件参考;红色:目标点或刀尖。颜色帮助区分对象,不代表电气接线。'),
'本讲义的符号是为讲清层次而定义的,不是所有控制器统一的界面名称。')
page('第2章 · 机床参考','08 回零解决什么?工件对刀又解决什么?',
p('回零建立机器执行机构的参考,使控制器知道关节位置相对于设定基准如何计量。它解决“机床自己在哪里”的问题。工件可能每次装在不同位置,回零并不会自动知道零件角点或孔中心在哪里。')+
p('工件对刀、测头找正和设置工件偏置,解决“这次装夹的零件参考在哪里”的问题。这两类基准相互有关,但不能互相替代。')+
table(['操作','主要作用','不会自动完成'],[['回零/HOME','建立关节或机床参考。','不会自动测得G54零点。'],['选G54','选择已保存的编程参考。','不会自动重新测量工件。'],['设置工件零点','更新零件与基础参考的配准。','不会重新标定转台中心。'],['测刀/刀长标定','建立主轴参考与刀尖的关系。','不会确定所有工件方向。']])+
box('G28不等于点击HOME','在LinuxCNC中,G28涉及预定义返回位置;它不是本项目回零按钮的同义词。初学阶段不要仅凭“回原点”的中文说法混用两种动作。'),
'回零、找工件零点、测刀,是不同的准备步骤。','[S2][S4];实际回零方法取决于机床配置。')
page('第2章 · 工件参考','09 工件坐标 W 代表什么?',
p('工件坐标是一套为零件几何与加工意图服务的参考。设计人员可能把顶面角点定义为(0,0,0),把孔中心写成(30,20,−5)。CAM和操作者必须采用兼容的约定,程序中的点才会落在预期部位。')+
fig('datum-choice','原点可以选角点、中心、基准面交点等;不一定是转台中心。',260)+
p('零件本身并没有天然写着“这里必须是X0”。原点是根据图纸、工艺、装夹和测量便利性选定的。选好以后,需测量或标定出它与机床结构的关系。')+
box('原点自己的坐标永远为零','工件原点在工件坐标中一直是(0,0,0)。但它在机床空间的位置可能随转台转动。两个说法并不矛盾,因为参考系不同。'),
'原点是工艺约定,控制器必须得到该约定与实际装夹之间的对应关系。')
page('第2章 · 工件随动','10 工件转了,原点与方向一起转',
fig('moving-origin','零位工件原点o=(40,20,10)mm;只看XY俯视,Z另计。',310)+
p('设工件原点在零位参考中是(40,20,10)。当C=0°时,它在机床空间仍在(40,20,10)。当C=90°、旋转中心为零时,它移到(−20,40,10)。')+
p('此时工件+X方向也转成机床+Y方向。工件上的P=(10,0,5)始终是“从工件原点沿工件X走10,再沿工件Z走5”,但它在机床空间从(50,20,15)变到(−20,50,15)。')+
box('两个都要算','仅改变坐标方向、却让原点一直留在原地,一般不正确。只有原点恰好位于所转轴线上等特殊情形,原点才可能不移动。'),
'随工件固连的坐标架是一个整体:原点和三根方向箭头一起变换。')
page('第2章 · 最重要的区分','11 “物理工件坐标架”和“G54表”不是同一对象',
p('物理上,你可以想象把一套坐标箭头画在工件上。转台转动时,这套箭头会随工件一起转。软件里,G54则是用于选取一套工件坐标设置的指令,其数据保存在控制器的偏置表中。')+
p('在本课RTCP模型中,G54主要给出零位配准偏置;姿态改变时的空间变换由另一层运动学完成。因此,偏置表中的数字可以保持不变,但它所对应的物理工件原点会在机床空间移动。')+
table(['层次','本课中的作用'],[['零件上的W坐标架','描述固定在零件上的位置和方向。'],['G54等偏置数据','告诉解释器如何把编程坐标换算到基础输入K。'],['RTCP运动学','利用当前A/B、C及几何参数把K换算成物理关节目标。']])+
p('如果只打开G54而没有正确的五轴运动学,控制器并不会因此自动追踪物理工件架的旋转。非RTCP时,软件的直接坐标解释与转动后的零件局部方向可能不一致。')+
box('不要过度推广','不同控制器可能把工件旋转、倾斜工作平面、TCP功能组合成不同指令。本课展示一条明确的数据链,不能把普通三轴G54直接称为完整五轴随动变换。'),
'G54负责选参考数据,RTCP负责根据结构和姿态换算运动。','[S1][S2][S4];后半为本课模型与项目实测数据链。')
page('第3章 · 原点与方向的确定','12 先约定机床零姿态,再谈工件装在哪里',
p('要描述工件装夹位置,必须先有一套明确的基础参考:旋转轴的零角度是什么姿态?倾转轴中心在哪里?C轴中心在哪里?直线轴与刀具基准怎样关联?这些由机床配置与几何标定决定。')+
p('把转台放到定义的A/B=0、C=0姿态,可以建立零位配准参考K。零件的工件原点相对于这套参考的偏置,记作o。若工件方向也存在初始夹角,还需要初始方向变换R₀。')+
eq('编程点 p_W → 零位配准 → p_K → 当前姿态变换 → 关节 q')+
table(['要确定的量','谁来提供'],[['轴的零角度、正方向','回零/编码器基准与机床配置。'],['旋转中心、轴间偏置','机床结构数据、测量与标定。'],['零件原点及初始方向','工艺定义、装夹、测头或找正。'],['刀具长度与参考点','刀具测量、刀表与补偿设置。']])+
p('这些数据不是点击“开启RTCP”时凭空生成的。RTCP只会使用已有数据计算,不能自动猜出实际工件的全部装夹关系。'),
'RTCP能正确跟随的前提,是机床几何与工件配准都已经正确。')
page('第3章 · 原点选择','13 工件原点放在哪里比较合适?',
p('常见选择有:毛坯顶面角点、工件中心、定位孔中心、某个基准面与两条基准边的交点。没有一种位置对所有零件都最优。应与图纸和CAM编程基准一致,并且能够稳定测量。')+
ol('先看图纸和CAM:尺寸从哪里标起,程序预期哪个位置为零?','再看装夹:该基准在机床上是否可接近、可找正、可重复?','确认方向:+X沿哪条边,+Y朝哪边,+Z朝工件外侧还是另一约定方向?','记录采用的是毛坯面、成品基准面还是夹具基准,避免同名不同面。')+
box('例子:顶面角点','若顶面定义为Z0,向材料内部加工的深度通常写成负Z;但这取决于你建立的+Z方向。不能看到“钻深5”就不加判断地写Z+5。')+
p('换一次装夹,零点位置可能变化。程序里零件局部几何可以保持不变,只更新该次装夹的配准数据。这正是工件坐标的价值。')+
p('转台中心可以作为机床标定基准,却不必与零件编程原点重合。把两者强行设成同一点,会让工艺选择受到不必要限制。'),
'方便编程、方便测量、装夹可重复,比“零点看起来居中”更重要。')
page('第3章 · 原点测量','14 怎样把选好的原点告诉控制器?',
p('首先必须让刀尖或测头与已知基准建立真实关系。可以使用测头、寻边器、对刀棒或其他已标定的测量方法。测量工具本身的半径、长度及接触位置要考虑进去;刀具中心碰到边,不等于刀尖已经位于所选坐标原点。')+
h('一种便于理解的零位流程')+ol('完成机床回零和刀具/测头标定。','把转台置于已知参考角度,按工艺要求找正工件。','测得原点相对于基础参考的位置,写入目标工件坐标表。','选择该坐标系,用已知的另一个点检查读数和方向。','用小幅、空载的受控运动验证,再进入实际加工。')+
box('在倾斜姿态下对刀更要谨慎','当前关节XYZ、RTCP输入K和工件局部坐标W可能不同。不能直接把屏幕上的J0/J1/J2当作G54偏置写入。应使用控制器提供的、与当前运动学兼容的坐标设置/测量流程。')+
p('本讲义的数字测量例仅解释偏置计算,不构成真实机床的探测轨迹。设备现场必须遵循该机床和控制系统的操作流程。','small'),
'找零点不是把三个显示框都随意清零,而是建立可验证的几何关系。')
page('第3章 · 方向测量','15 一个原点不能确定三维方向',
fig('orientation-measure','平面内示意:O给出位置,O→Pₓ确定+X,再用不共线点确定平面及+Y。',280)+
p('你把一个孔中心设为零,只确定了原点位置。围绕这个点,坐标架仍可以转动。要确定方向,还需要已知的边、面或更多测量点。')+
p('常见做法是让工件基准边与预定方向对齐,并用基准面确定法向。若需要软件方向补偿,应由控制器支持的工件坐标旋转或倾斜平面功能来表达,不能只修改X/Y/Z三个平移偏置。')+
box('完整三维配准','三个不共线的点可帮助确定一个平面及平面内方向,但仍须明确点的对应关系、顺序、正负号和右手规则。共线点无法唯一确定平面。'),
'原点回答“从哪里量”,方向回答“朝哪里量”;它们需要不同信息。')
page('第3章 · 初始方向与动态方向','16 装夹方向和转台姿态,要分开处理',
p('假设工件在C=0时,长边已经相对基础X方向偏转了20°。这20°是装夹的初始方向关系;后来C又旋转30°,属于机床当前姿态。两者来源不同,但都影响最终空间方向。')+
table(['量','何时确定','通常何时变化'],[['初始工件方向R₀','装夹/找正/CAM配准时。','重新装夹或修改坐标设置时。'],['转台角度A/B、C','回零基准与每条运动指令。','程序或Jog执行过程中。'],['几何中心P、偏置D','机床结构测量和标定时。','结构调整或重新标定时。']])+
p('LinuxCNC可用特定工件坐标设置表达XY平面内旋转,例如G10 L2的R参数;它不是一个任意三维倾斜平面描述。摆头/转台结构、倾斜平面和TCP功能的组合仍要按具体控制器核对。')+
box('本项目的数值实验范围','下面逐行实算只使用平移G54,即R₀为单位变换。讲义会解释一般方向关系,但不宣称当前Web示例已完整实现任意三维坐标旋转或所有厂商TCP指令。'),
'没有声明初始装夹方向时,不能擅自认为工件边一定平行于机床导轨。','[S4] G10 L2;项目实验边界见后文。')
page('第3章 · 原点和方向合起来','17 一个点怎样从工件坐标换算到机床空间?',
p('先不看公式。把工件原点在机床空间的位置标出来;再把工件+X、+Y、+Z三个方向换算到机床空间。最后,沿这些方向分别走程序给定的距离。')+
eq('空间目标 = 当前工件原点 + X·工件X方向 + Y·工件Y方向 + Z·工件Z方向')+
p('用数学记号写为 r_M = O_M + E_MW·p_W。O_M是工件原点在机床中的位置,E_MW的三列是工件三个单位方向在机床中的分量。矩阵只是把“三个方向加权相加”缩写起来。')+
fig('moving-origin','转台改变姿态时,O_M和E_MW一般都变化;工件点p_W可以保持。',270)+
box('方向与位置的不同','增量只用方向关系:Δr_M = E_MW·Δp_W,前提是这一增量过程中姿态保持不变。若角度也在变化,原点和方向同时变,必须计算完整运动过程。'),
'固定姿态时可以只变换增量;姿态在变时必须处理完整的点变换。')
page('第4章 · G54与偏置','18 G54究竟做了什么?',
p('G54选择第一套工件坐标设置。LinuxCNC中还有G55、G56、G57、G58、G59、G59.1、G59.2、G59.3,合计九套。你可以给不同工件、不同装夹或不同夹具保存不同偏置。')+
table(['选择指令','G10中的P编号','本页教学含义'],[['G54','P1','第一套坐标设置'],['G55','P2','第二套坐标设置'],['G56…G59','P3…P6','第三至第六套'],['G59.1…G59.3','P7…P9','第七至第九套']])+
p('G54具有模态性:选中后,后续适用的运动指令继续使用它,直到选了同组的另一个坐标系。单独执行G54没有轴目标时,不会自动让刀具移动到工件零点。')+
box('注意同一行可能同时有运动','“G54”只选择坐标系;但“G54 G1 X10”还包含运动目标,后者会按该坐标系及当前模式解释X10。不能只看到行里有G54就认定不会运动。'),
'选择参考,不等于走到参考原点。','[S2][S4] 坐标选择;实测G54/G55见gcode-evidence.json。')
page('第4章 · 偏置的物理含义','19 G54的40、20、10代表什么?',
p('本课统一例子:在零角度、零几何偏置且方向对齐的教学模型里,设工件原点相对于K参考为o=(40,20,10)mm。意思是先沿K的X方向40、Y方向20、Z方向10,才到工件原点。')+
code('G21 G90\nG10 L2 P1 X40 Y20 Z10\nG54')+
p('当程序要求工件点X10 Y0 Z5时,解释器得到K输入(50,20,15)。到这一步还没有完成五轴运动学计算;随后还要依据B/C或A/C及几何参数得到关节目标。')+
fig('offset-pipeline','同一例子继续取C=90°,RTCP最终关节XYZ为(−20,50,15)。',265)+
p('因此,G54偏置中的40不能理解成“无论转台怎么转,工件原点都固定在机床X=40”。它是零位配准数据,动态空间位置由RTCP继续换算。','small'),
'工件偏置先进入编程坐标解释,转台姿态随后进入运动学。','本项目WASM解释器与运动学实测;o=(40,20,10)、P=D=L=0。')
page('第4章 · 设置方法一','20 G10 L2:把已知偏置写入坐标表',
code('G21\nG10 L2 P1 X40 Y20 Z10'),
'这条指令设置参考数据,不是命令XYZ运动到(40,20,10)。','[S4];同程序实际执行未产生运动段。')
# Fill the page separately to keep the address-word explanation readable.
PAGES[-1]['body']+=table(['地址字','本行含义'],[['G10','设置某类数据,具体对象由L等参数决定。'],['L2','本例为直接设置工件坐标偏置。'],['P1','第一套工件坐标设置,即G54。'],['X40 Y20 Z10','在本课约定下,直接写入三项长度偏置。'],['G21','当前长度单位是mm;不要把单位上下文省略。']])+p('G10 L2适合“已经知道偏置是多少”的情况。它不是“把当前位置显示成某个值”的自动计算命令。若误把L20的用法套在L2上,偏置会被设置到完全不同的值。')+box('G10里的X不一定是运动目标','同一个地址X,在运动段里可以表示目标;在G10设置段里表示配置参数。一个数到底有什么作用,必须结合该行的G代码和L/P上下文。')
page('第4章 · 设置方法二','21 G10 L20:让当前位置具有指定工件坐标',
p('假设刀尖已处在本课K位置(70,30,20),你确认它就是工件原点。想让此时工件读数变为(0,0,0),控制器需要把G54偏置设置为(70,30,20)。')+
code('G10 L20 P1 X0 Y0 Z0')+
eq('偏置 o = 当前位置 p_K − 希望此刻显示的工件坐标 p_W')+
p('若当前位置仍为(70,30,20),但希望此点显示为工件X10 Y0 Z0,则偏置变成(60,30,20)。改变的是坐标设置,不是机床执行轴的位置。')+
table(['动作','K中的X','G54工件X'],[['刀尖保持当前位置','70','取决于原偏置'],['L20 P1 X0','70','0'],['之后G54 G90 G1 X10','80','10']])+
box('倾斜时不能随意代入关节','公式中的当前位置必须是该控制器用于工件坐标设置的参考位置。本课RTCP下它是经正解/同步的K,通常不是J0/J1/J2。实际使用前应按控制器的TCP兼容测量流程确认。'),
'L2写“偏置是多少”;L20给“当前位置应读成多少”,由控制器算偏置。','[S4];零旋转实测L20偏置与后续X10运动。')
page('第4章 · 两个坐标系','22 换G54/G55为什么可能只改数字、不动机床?',
p('给G54设置o₁=(40,20,10),给G55设置o₂=(80,−10,10)。刀尖已在K=(50,20,15)。选择不同坐标系时,同一位置会产生不同局部读数。')+
table(['状态','关节/刀尖是否立即移动','读数或下一步目标'],[['当前G54','否','W₁=(10,0,5)'],['单独选择G55','否','W₂=(−30,30,5)'],['再执行G90 G1 X10','是','K的X变为90;未写的Y/Z保持原位置']])+
p('同一个X10在G54和G55中指向不同目的地。若程序意图是加工两个不同装夹位置的相同零件,这正是需要的行为;若是无意中选错坐标系,就会整段偏移。')+
box('工件坐标切换与RTCP切换不是一回事','G54→G55更换配准数据;M429→M428更换运动学解释。两类切换本身都不应作为“无目标的移动指令”,但它们改变后续运动字值的含义。')+
p('下方数值使用方向对齐的参考K来展示偏置。转台已倾斜时,物理空间关系还要经过RTCP变换。','small'),
'换尺子可以只改变读数;按新尺子再给目标,才可能让机床运动。','[S2][S4];G54/G55选择及省略轴实测。')
page('第4章 · 附加偏置与基础坐标','23 G52、G92、G53:先学会不混用',
table(['指令/功能','通用理解','初学者要点'],[['G52','在已有工件参考下使用局部偏置。','不是自动测得新的装夹原点。'],['G92','通过坐标重新定义产生附加偏置。','可能与持久化设置有关,不能假设程序结束就完全清掉。'],['G53','本段使用控制器定义的机床坐标方式。','非模态;不等同于HOME,也不保证直接控制电机关节。']])+
p('多层偏置叠加后,程序点到基础参考的换算不再只有一个G54。排错时必须查看当前有效的所有层,不能只盯着G54表里的三个数字。')+
p('尤其在非平凡运动学机床上,控制器的“机床坐标”仍可能是运动学输入空间。G53不意味着跳过所有运动学,G53 Z0也不能被当作通用安全退刀动作。')+
box('本讲义的实验边界','主程序刻意不用G52/G92,也不依靠G53绕过RTCP。这一页讲官方语义与排错意识;当前Web切片没有在本课中完成这些功能的全行为验收。'),
'只在理解所有有效偏置后再组合使用;初学实验先保持参考层次简单。','[S2][S4];对G53关节语义需按具体运动学核对。')
page('第5章 · 非RTCP与RTCP','24 非RTCP:“独立”的准确含义',
p('对于本课直接映射模式,经过单位/偏置解释后,X/Y/Z对应直线关节,A/C或B/C对应旋转关节。只命令C转动,不会自动追加保持刀尖相对工件点的XYZ补偿。')+
fig('identity-no-follow','只转C:工件标记点被带走,刀尖留在机床原位置。',255)+
p('这不表示各轴不能同时运动。例如一行同时给X、Y、B、C目标,控制器仍可协调插补。也不表示零件与刀具之间“完全没有关联”:物理工件转了,二者的相对位置当然会变。')+
box('没有RTCP也可以五轴加工','CAM/后置可以事先针对指定机床结构计算各轴轨迹,交给控制器执行。但换机床几何、刀长或装夹后可能需要重新计算;它与控制器实时进行RTCP不是同一机制。'),
'独立是“没有自动的姿态几何补偿”,不是“没有同步插补或相对运动”。')
page('第5章 · 非RTCP与RTCP','25 RTCP控制的是相对目标关系',
p('RTCP可理解为旋转姿态变化时的刀尖位置补偿。给定目标工件点以及A/B、C角度,控制器结合机床几何计算所需关节位置,让刀尖实现规定的相对工件轨迹。')+
fig('tcp-follow','保持同一工件点,角度改变时XYZ按需跟随。图为零偏置简化模型。',255)+
p('“刀尖保持”有两种不同说法:保持在机床空间同一点;保持相对工件的同一点。本课RTCP讲的是后者。工件转动时,该点在机床空间可能沿弧线移动,所以刀尖也需要移动。')+
box('正确的观察方式','在工件上选一个离旋转轴有距离的标记点,先让刀尖对准它,再保持编程XYZ、改变角度。应看刀尖与标记点是否继续对齐,而不只是看屏幕XYZ有没有变。'),
'RTCP不要求刀尖在世界里绝对不动,而要求它满足指定的相对工件关系。','[S1] 五轴运动学;本项目原生AC/BC正逆解。')
page('第5章 · RTCP工件原点','26 用G54例子看“原点随动”',
p('现在使用贯穿后续章节的例子:BC结构,B=0;几何中心P=0、偏置D=0、刀长差L=0;G54零位偏置o=(40,20,10)。程序目标始终为W=(10,0,5)。')+
table(['C角度','工件原点的机床位置O_M','刀尖目标/关节XYZ','程序W'],[['0°','(40,20,10)','(50,20,15)','(10,0,5)'],['90°','(−20,40,10)','(−20,50,15)','(10,0,5)']])+
fig('moving-origin','两种姿态下,刀尖相对工件原点的局部分量始终相同。',270)+
p('注意:G54表仍是40、20、10,没有每次改写成−20、40、10。后一个值是由当前姿态算出来的空间位置,不是要求操作者实时改G54。','small'),
'固定的是工件内的定义与零位配准数据,变化的是它们在机床空间的表达。','本课WASM逐字解析和逆解核验;详细行表见第7章。')
page('第5章 · RTCP方向','27 沿工件X前进,哪些电机会动?',
p('在C=45°、倾转角为0°时,工件+X方向斜着指向机床+X和+Y。沿工件X增加10mm,必须由两根固定导轨合成:机床X约增加7.071mm,机床Y约增加7.071mm。')+
fig('rotated-direction','绿色方向随工件,蓝色机床导轨方向不转。',295)+
eq('姿态固定:Δq_X = 10 cos45°;Δq_Y = 10 sin45°')+
p('XYZ导轨并没有在机械上旋转。变的是工件方向在机床中的表达。编程沿一根工件轴走,实际可以由两根或三根直线关节一起完成。')+
box('原点为何没有出现在增量式里?','因为这一页姿态固定,比较的是两个目标点的差,平移项相减后消失。若B/C也在同一过程中变化,就不能只用这条固定方向增量式。'),
'一个X字,不一定只驱动X电机;先问它属于哪套参考系。')
page('第5章 · 倾转与结构顺序','28 加入B或A后,方向可以进入三个维度',
p('BC结构中,B倾转带着C轴一起倾斜。采用本项目约定,零偏置变换为R=R_Y(B)R_Z(C)。当B=30°、C=45°时,工件+X的机床方向分量约为(0.6124,0.7071,−0.3536)。')+
fig('bc-frame','保持B=30°、C=45°时,沿工件X前进10mm,会同时改变三个直线轴。',300)+
eq('Δq ≈ (6.124, 7.071, −3.536) mm')+
p('AC则使用R_X(A)R_Z(C)。同样写30°、45°,AC与BC的刀尖空间位置一般不同。矩阵从右向左作用,表达父子结构的坐标转换顺序,并不是要求先完成C动作再单独执行A/B。')+
p('这里的正方向采用本项目原生计算约定;实际机械正转、工件视角和摄像机视角可能使视觉直觉相反,必须用正确标定核对。','small'),
'RTCP用的是完整机构链,不能只把A字母改成B就期待相同空间姿态。')
page('第5章 · 三类偏置','29 G54、旋转中心、轴间偏置各管什么?',
table(['参数','属于什么关系','不能拿来替代'],[['G54偏置o','零件编程原点与基础参考K的配准。','不是转台的物理轴心标定。'],['rot-point P','本项目原生TRT的旋转参考位置。','不是每次装夹都应随便改的零件零点。'],['axis offset D','倾转轴与C轴之间的结构偏置。','不是普通工件坐标平移。'],['tool-offset L','刀具参考与受控点的标定量。','不是看到网格长度后直接填的任意长度。']])+
p('这些量都可能以mm显示,但含义和进入公式的位置不同。如果刀尖偏差源于转台中心错误,单靠修改G54可能只在某个角度碰巧对齐,换一个姿态就再次偏离。')+
fig('pivot-offset','起点相同、角度相同,绕不同中心会产生不同目标位置。',235)+
box('标定与装夹分开','机床结构参数应通过结构测量和标定确定;工件偏置用于表达该次装夹。教学可以故意改参数做对照,但真实设备不能把结构标定当成随手找零工具。'),
'看到“偏移多少毫米”时,还必须问:是哪个参考系之间的偏移?')
page('第5章 · 刀具参考','30 为什么必须知道刀尖究竟在哪里?',
p('电机驱动的通常是滑台和主轴箱,程序关注的却是刀尖。主轴参考点与刀尖之间相隔一段刀具长度。如果更换刀具后长度不同,保持同一主轴位置并不能保持同一刀尖位置。')+
fig('tool-length','相同刀尖目标:刀更长时,主轴参考位置需要按正确约定调整。',250)+
p('在LinuxCNC的完整刀补语义中,G43 H3表示使用指定刀具长度偏置条目;H3不是“刀长等于3mm”。T表示工具选择,M6表示换刀流程,S表示主轴速度,也不能与刀长混用。')+
box('本项目边界特别说明','主数值实验统一L=0。当前Web解释器切片的H/G43处理不能当作完整LinuxCNC刀表语义的验收依据,因此讲义不把“程序成功解析”冒充“完整刀补行为正确”。真实应用须使用经过验证的刀表和测刀链路。')+
p('RTCP、刀长补偿、倾斜工作平面是相互配合的功能,不是一个开关的三个名字。','small'),
'先标定刀具参考点,再讨论刀尖目标;网格看起来像刀具,并不代表它已正确标定。','[S4] G43;本项目sim_interpreter.c的能力范围与本课测试边界。')
# Remaining chapters are appended below, then rendered as fixed A4 pages.
page('第6章 · 读懂G代码','31 一行G代码由“地址字”组成',
code('N50 G1 X10 Y0 Z5 B0 C0 F600')+
p('一个地址字通常由字母和数值组成,例如X10。字母说明这个数属于什么类别;当前G/M代码和模态状态进一步决定它的具体作用。空格用于阅读,不是每个空格后就执行一次运动。')+
table(['字','本行解释'],[['N50','程序段编号,便于识别,不是运动距离。'],['G1','按进给方式生成直线类型的运动段。'],['X10 Y0 Z5','在当前绝对/增量和坐标设置下给出XYZ。'],['B0 C0','给出旋转轴角度目标;本课为BC结构。'],['F600','设置进给值,含义受进给模式和单位影响。']])+
p('控制器应先读完整个程序段,检查语法与模态组,再求出整体目标。不能把它理解成先走X10、走完再走Y0、再走Z5、再转B和C。')+
p('本课旋转轴的工件偏置为零,所以B/C字直接成为相应关节角度目标;若启用了角度偏置,也要先换算,不能只看字面角度。','small')+
box('先问三个问题','当前在用哪套坐标?G90还是G91?RTCP是否已开启?这三项不清楚,仅看X10这个字不能确定电机终点。'),
'地址字是数据;程序段和当前模式共同决定数据的含义。','[S5] G-code Overview;N50为项目实测程序段。')
page('第6章 · 模态状态','32 没有写出来的内容,可能沿用上一行',
p('模态可以理解为控制器记住的当前设置。G1、G90/G91、G20/G21、G54等在不同模态组中生效。后续行省略这些字时,常会沿用之前的设置。')+
code('G21 G90 G54 G1 X10 Y0 Z5 F600\nC90\nG91 X10\nG90 X20')+
p('第二行只有C90,但它仍处于G1、G90、G21、G54环境,F仍为600。第三行把距离模式切换成G91,X10变成增量。第四行恢复G90,X20又变成绝对目标。')+
table(['省略什么','通常怎样理解'],[['省略G1','沿用有效运动模式;不是自动停止解析。'],['省略F','在允许沿用的进给模式下保留原值;G93另有要求。'],['省略XYZ某一轴','通常保持该坐标分量的当前目标,不自动归零。'],['省略角度字','保持当前角度目标,不自动回零角度。']])+
box('同组冲突','不要在同一段同时写互相矛盾的同组模式,例如G90和G91。合法的执行顺序不等于可以把冲突指令堆到一行。'),
'读一行程序时,要带着“当前状态表”一起读。','[S4][S5] 模态与程序段;本文只涵盖常用运动模式。')
page('第6章 · 单位','33 G20/G21改变长度单位,不把角度变成长度',
table(['数值类别','G21示例','G20示例'],[['X/Y/Z长度','X1为1mm','X1为1inch=25.4mm'],['A/B/C角度','本课以度计','仍按控制器旋转角约定,不把1°换成25.4°'],['G94直线进给F','F600通常为600mm/min','F2通常为2inch/min=50.8mm/min'],['G93的F','逆时间,见进给专页','不是长度值,不能机械乘25.4']])+
p('本课WASM已实测:G20 G91 X1产生25.4mm的直线增量,G94下F2换算为50.8mm/min。然后换回G21时,后续长度又按mm解释。')+
p('变量、刀表、测量结果以及URDF还可能使用各自定义的单位。教学数据进入某一接口前应先统一,不要因为屏幕只显示一个数字就忽略单位。')+
box('一个常见大错误','把毫米程序放在英寸模式下运行,会让每个长度字放大25.4倍。反过来也不正确。程序头明确声明单位,通常比依赖上次运行残留状态更可靠。'),
'长度、角度、转速、时间、索引号是不同量纲;不能用一个换算规则处理所有数字。','[S4] G20/G21/G93/G94;长度/直线进给转换已实测。')
page('第6章 · 绝对目标','34 G90 X10:去当前编程系的X=10',
p('在G90中,X10通常表示当前有效编程参考系中的绝对目标X值为10。它不是“再走10”,也不是“无论有什么偏置,电机都去10”。')+
p('本课条件:G54的o=(40,20,10),方向对齐,B=0、C=90°,RTCP开启,当前目标W=(20,0,5)。执行G90 G1 X10,新的W目标变为(10,0,5)。')+
table(['计算层','新目标'],[['工件坐标W','(10,0,5)'],['加G54得到K','(50,20,15)'],['C=90°后关节XYZ','(−20,50,15)']])+
p('原来W=(20,0,5)对应关节(−20,60,15),所以这一动作让机床Y减少10mm。虽然程序只写X10,实际发生变化的是本例的Y关节。')+
box('不要把G90看成“机床坐标开关”','G90只是绝对距离模式。它仍与G54等坐标设置共同起作用,并没有替代工件坐标系选择。'),
'“绝对”必须说明是相对于哪套当前有效参考,而不是自动等于机床电机绝对位置。')
page('第6章 · 相对增量','35 G91 X10:在当前编程方向增加10',
p('仍用G54 o=(40,20,10)、B=0、C=90°、RTCP开启。当前位置W=(10,0,5)。执行G91 G1 X10后,W变为(20,0,5),K从(50,20,15)变为(60,20,15)。')+
eq('新目标W = 旧目标W + (10,0,0)')+
p('角度保持C=90°,因此该增量在机床空间沿+Y方向,关节从(−20,50,15)变为(−20,60,15)。偏置不应被重复加两次。')+
code('G91 G1 X10\nG90')+
p('如果同一段还有B10或C20,它们在G91下通常也表示角度增量。控制器先形成新的整体坐标/角度目标,再插补并求解;不能把变化的姿态当成整段保持不变,只旋转一次XYZ增量就结束。')+
box('省略轴的正确理解','G91只写X10时,未写的轴不增加数值;在RTCP中,为实现这个编程增量,多个物理关节仍可能同时变化。记得之后是否需要恢复G90。')+
p('旋转轴还可能有展开角度或回绕设置。C=0与360的空间方向可以相同,但关节位置、行程和运动路径未必相同;本课不假设控制器自动选择最短转动路径。','small'),
'G91改变目标的形成方式,不取消工件方向和RTCP计算。')
page('第6章 · 运动方式','36 G0/G1分别如何产生运动?',
p('G0用于快速定位类运动,G1用于按进给规则执行直线类型运动。给出的通常是该段目标,而不是每个伺服时刻的位置。控制器的规划器决定加减速、插补进度及与相邻段的衔接。')+
table(['层次','G1 XYZBC在本课中的处理'],[['编程空间','定义XYZ与角度的起点/终点以及运动类型。'],['插补','逐步得到中间位置和中间角度。'],['运动学','每个采样按该时刻姿态和结构求解关节。'],['执行','输出连续的关节位置/速度,并检查限制。']])+
p('G1说的“直线”属于控制器定义的编程空间运动,不意味着所有机械部件在世界中都走一条直线。保持同一工件点、转动C时,刀尖可以在机床空间走弧线。')+
box('不要只检查终点','如果在两个端点做了正确逆解,却在关节之间用不相符的方式连线,中途可能偏离工件轨迹。正确RTCP必须在整个运动过程中维持目标关系。')+
p('G0的具体协调方式、G64圆滑和G61精确路径等规则因控制系统而定。初学者不应把快速定位当作任何场景下的安全路径。','small'),
'程序给段目标,规划器给连续过程,运动学给各轴实现方式。','[S4] G0/G1;项目规划器与运动学实测。')
page('第6章 · 圆弧地址','37 G2/G3里的I、J、K不是三根新电机轴',
p('G17通常选XY圆弧平面;G18、G19选择其他主要平面。G2/G3给出圆弧方向,圆心可以由I/J/K等地址表达。圆心方式有独立的绝对/增量规则,不能仅看G90/G91就猜圆心如何解释。')+
code('(教学条件:G17,圆心增量方式)\n(起点 X10 Y0,圆心 X0 Y0)\nG3 X0 Y10 I-10 J0 F300')+
fig('arc-words','圆心增量从圆弧起点量:I=-10、J=0;不是从工件原点量两个绝对坐标。',245)+
p('本例圆心增量等于“圆心−起点”。R有时也可表示圆弧半径,但它在其他指令中又可能表示不同参数。姿态固定时,工件平面中的圆弧映射到机床中的相应倾斜平面。')+
box('本课不混合演示','圆心模式、整圆/多圈、R长短弧以及圆弧期间同时转台运动,需要按控制器文档逐项确认。不要把一段已知平面圆弧随意扩展成未验证的复杂五轴程序。'),
'同一个字母的含义由当前指令规定;I/J/K经常是圆心参数,不是位置轴。','[S4] G2/G3、G17–G19、G90.1/G91.1。')
page('第6章 · 进给','38 F到底是速度、时间,还是每转进给?',
table(['模式','F的基本含义','例子'],[['G94','每分钟进给;线性、纯旋转或混合段需按控制器规则。','直线段F600mm/min≈10mm/s。'],['G93','逆时间进给,F的单位为1/min。','F2对应理想段时间1/2分钟=30秒。'],['G95','每主轴转一圈的进给量。','F0.1mm/rev,配合实际主轴转速。']])+
p('RTCP下,仅仅保持编程XYZ、改变角度,也可能引发XYZ关节移动。F不等于“每根电机都以这个数值运动”。各关节速度是插补过程和运动学变化共同作用的结果。')+
p('LinuxCNC对G94纯旋转段和含直线分量的运动有具体规则;G93可用于表达段时间,但仍受速度、加速度等限制。G93下G1/G2/G3通常每段需要F,不能直接照搬G94省略F的习惯。')+
box('单位计算只说明请求,不保证实际达成','F600=10mm/s是单位换算。实际运动可能因加减速、限位和机床能力更慢。控制器不应为了追求F而越过能力约束。')+
p('当前Web切片的全部进给模式并非本课程验收对象;主算例采用G94,额外模式依据官方语义讲解。','small'),
'看到F,先查进给模式;判断轴运动,还要查规划器和机器约束。','[S3][S4] G93/G94/G95。')
page('第6章 · 其他常见字','39 不是每个数字都代表位置',
table(['字/指令','常见含义','容易误解的地方'],[['S12000','主轴转速请求。','不是刀具线速度或XYZ距离。'],['T3 / M6','选择工具号 / 执行换刀流程。','不等于自动设定工件原点。'],['G43 H3','使用指定刀长偏置条目。','H3不是刀长3mm。'],['D','常见为刀具半径补偿索引。','不是通用“深度”字。'],['G10 L2 P1','L指定设置类别,P选目标坐标表。','P1不是转台转1°。'],['G4 P…','停留参数,按控制器定义。','同一P在G10里却是编号。'],['R / Q','圆弧/循环/设置中的上下文参数。','不能固定解释为半径或角度。'],['M3/M5/M8等','主轴/冷却等辅助动作。','不是每个M码都有跨品牌同义。']],'compact')+
p('本项目M428/M429是重映射到运动学切换和同步的命令。其他控制器可能采用完全不同的代码组合;把本项目的编号直接复制到真实机床上没有通用性。')+
box('逐字阅读的方法','先识别G/M指令和当前模式,再给每个地址字标注“长度、角度、速度、时间、编号或设置值”,最后再计算目标。'),
'字母相同不保证意义相同;上下文比数字本身更重要。','[S4][S5];M428/M429为本地INI重映射。')
page('第6章 · 从文本到运动','40 控制器不是从左到右逐个字移动',
ol('读取程序段:识别注释、地址字、数值和参数表达式。','检查语法与模式:单位、距离方式、坐标系、运动类型是否有效且无冲突。','形成整体目标:G90写绝对值,G91加增量;处理省略轴和当前状态。','应用坐标设置:工件偏置、允许的坐标旋转/补偿,得到规范输入。','规划轨迹:速度、加速度、路径衔接和同步屏障。','逐采样计算运动学:根据该时刻位置、角度、中心/偏置/刀具数据求关节。','执行与反馈:伺服驱动/仿真推进;界面再显示实际状态与轨迹。')+
p('同一行中的X和B是一个联合目标,不是先走X再转B。控制系统有规定的执行顺序与模态语义;文本排列不能代替这些规则。变量和表达式也先按解释器规则求值,再成为数值输入。')+
box('“解析成功”不等于“可以安全运动”','后面还可能发生缺少回零、越限、刀补不完整、速度约束或几何错误。教学验证至少要看解释结果、规划结果和实际采样。'),
'每一层都应有可观察的输出,不能把程序文本直接当成模型关节值。','[S5] 程序段规则;本项目解释器→TP→WASM→模型链路。')
page('第7章 · 完整实算程序','41 统一实验条件与完整程序',
p('以下每一行都用相同数据核验:BC双转台;旋转中心P=0、轴间偏置D=0、刀长差L=0;工件方向与零位K对齐;G54偏置o=(40,20,10)mm;初始物理关节均为0。')+
code('\n'.join(DATA['rows'][i]['block'] for i in range(len(DATA['rows'])))+'\nM2')+
p('N10–N90加M2已通过真实项目解释器、LinuxCNC TP和WASM运动学完整执行;N100/N110另用保持物理关节的切换接口逐段验证。每条运动的关键终点都与解析+逆解结果对照。','small')+
box('下载示例与教学参数不同','本项目下载GLB自带非零中心/偏置。这里为讲清G54与RTCP分层,使用独立零几何测试配置;不是在任意当前机床状态下粘贴代码就应得到同样数字。'),
'本章的数字是固定条件下的可复算例子;改变条件,结果也会改变。','gcode-evidence.json:rtcp-beginners-gcode-v1;完整程序状态completed。')
page('第7章 · 第一步','42 N10–N30:设置模式与工件参考',
code('N10 G21 G90 G94 G17\nN20 G10 L2 P1 X40 Y20 Z10\nN30 G54')+
table(['程序段','改变什么','是否生成运动'],[['N10','单位mm、绝对方式、每分钟进给、XY平面。','没有目标轴,不运动。'],['N20','把G54偏置设为(40,20,10)。','设置数据,不运动。'],['N30','选择G54为有效坐标系。','单独选择,不运动。']])+
p('初始物理关节仍是0。G54偏置设定后,原来的空间位置在这套编程坐标中可显示为(−40,−20,−10)。不是刀具突然往负方向跑了,而是这套尺子的原点不在刀具当前位置。')+
box('读数变化的第一种原因','修改偏置表就可能改变当前相对读数。验证有没有运动,要检查关节/反馈和刀尖物理位置,不能仅凭屏幕上相对XYZ变化判断。')+
p('N10的G17目前只是声明圆弧平面。本程序只有直线类型段,不会因为写了G17就产生一段圆弧。'),
'程序准备阶段可能只改解释状态,还没有开始移动。')
page('第7章 · 第二步','43 N40 M428:先切模式,不凭空移动',
code('N40 M428')+
p('本项目中,M428选择RTCP运动学并同步解释器与运动状态。切换时要先读取当前物理关节,再用新模式正解出对应的坐标。它不是“保持旧XYZ数字,再反算出一组新的关节”。')+
p('在本章初始姿态下,关节为0、角度为0、几何偏置为0。Identity和RTCP在这个姿态碰巧给出相同的基础位置K=(0,0,0),因此G54相对读数仍为(−40,−20,−10)。')+
table(['量','N40之后'],[['关节XYZ/B/C','全部保持0。'],['基础输入/同步坐标K','(0,0,0)。'],['G54相对XYZ','(−40,−20,−10)。'],['运动学模式','从Identity改为本项目TCP。']])+
box('不能由这个特例推广','角度不为零或中心/偏置不为零时,切换后的坐标数值一般可能改变。但关节与物理刀尖仍应保持。后面N100/N110会看到明显的数字差异。'),
'模式切换准备新的坐标解释;有明确运动段时才产生后续轨迹。')
page('第7章 · 第三步','44 N50:把第一个工件点送到刀尖',
code('N50 G1 X10 Y0 Z5 B0 C0 F600')+
ol('G90生效:新的工件XYZ目标为(10,0,5),不是在旧位置上增加。','B0/C0给出零角度;F600更新G94进给请求。','加G54偏置:(10,0,5)+(40,20,10)=(50,20,15)。','旋转角度为零、几何P/D/L均为零,逆解XYZ与K相同。','关节目标得到(50,20,15,0°,0°),随后由规划器从当前位置插补到此。')+
table(['层次','数值'],[['程序工件点W','(10,0,5)mm'],['运动学输入K','(50,20,15)mm'],['XYZ关节目标','(50,20,15)mm'],['B/C关节目标','0° / 0°']])+
box('Y0和Z5的读法','Y0指向工件Y=0,不是把机床Y归零;本例机床Y目标实际是20。Z5是工件基准面上方5mm,前提是本课+Z方向朝该侧。'),
'先算工件参考里的目标,再算偏置和运动学,不跳过中间层。')
page('第7章 · 第四步','45 N60 C90:XYZ字没出现,XYZ关节却动了',
code('N60 C90')+
p('G1、G90、G21、G54、F600仍然有效。只更新C的绝对目标为90°,工件XYZ仍保持(10,0,5),B仍为0。运动学输入的XYZ继续为(50,20,15)。')+
eq('q_X = 50cos90° − 20sin90° = −20<br>q_Y = 50sin90° + 20cos90° = 50<br>q_Z = 15')+
table(['比较','N50末端','N60末端'],[['工件XYZ','(10,0,5)','(10,0,5)'],['基础K的XYZ','(50,20,15)','(50,20,15)'],['关节XYZ','(50,20,15)','(−20,50,15)'],['B/C','0° / 0°','0° / 90°']])+
p('工件原点本身也从(40,20,10)移动到(−20,40,10)。在新姿态下,再沿工件X走10、Z走5,就到刀尖目标(−20,50,15)。')+
box('这就是RTCP跟随','省略XYZ不表示禁止XYZ电机动作;它表示程序点不变。为了维持这个点相对工件的位置,XYZ关节可以需要运动。'),
'程序量保持与关节量保持,是两个完全不同的约束。')
page('第7章 · 第五步','46 N70 G91 X10:沿转后的工件方向走',
code('N70 G91 X10')+
p('这一行把距离模式改为增量。原工件点W=(10,0,5),现在沿工件+X增加10,得到W=(20,0,5)。B/C没有写,保持0°/90°。')+
table(['步骤','结果'],[['形成工件终点','(10,0,5)+(10,0,0)=(20,0,5)'],['转换到K','(20,0,5)+(40,20,10)=(60,20,15)'],['C=90°的逆解','关节XYZ=(−20,60,15)'],['相对上一步的关节变化','Δq=(0,+10,0)mm']])+
p('所以程序写了X10,机床Y关节却增加10mm。这不是轴接错,而是“工件+X”当前指向机床+Y。图形上刀尖沿转动后的工件方向移动。')+
box('偏置不要重复累加','G91是在当前编程位置上增加位移。错误地把旧K位置再加一遍G54偏置,会让目标多出(40,20,10)。本课解析实测验证没有这种重复偏置。')+
p('N70之后G91仍有效。下一行如果继续写X20而不恢复G90,就又是增加20,而不是去X=20。'),
'每次出现G90/G91,都应更新你的“当前模式表”。')
page('第7章 · 第六步','47 N80:工件点不变,B倾转带来X/Z补偿',
code('N80 G90 X20 Y0 Z5 B30 C90 F300')+
p('先恢复G90,工件XYZ绝对目标仍为(20,0,5),与N70末端相同。C保持90°,B从0°变为30°,F改为300。K的XYZ保持(60,20,15)。')+
p('先按C=90°换算,得到(−20,60,15);再按B=30°绕Y方向倾转:')+
eq('q_X = (−20)cos30° + 15sin30° ≈ −9.821<br>q_Y = 60<br>q_Z = −(−20)sin30° + 15cos30° ≈ 22.990')+
p('此时工件原点的空间位置约为(−12.321,40,18.660)。工件X方向当前沿机床+Y;工件Z方向则含机床X和Z两个分量。把W=(20,0,5)按这些方向加到原点上,也得到同一个关节XYZ。')+
box('两种计算方式,结果相同','可以“先形成K,再按转台链变换”;也可以“求当前工件原点与基方向,再组合W点”。它们是同一几何关系的两种表达,不应产生两套不同答案。'),
'倾转只改变B字,也可能要求直线轴联动;这正是补偿的任务。')
page('第7章 · 第七步','48 N90:省略的Z、B、C保持当前目标',
code('N90 X0 Y10')+
p('因为N80恢复了G90,这里的X0/Y10是工件绝对目标,而不是增量。Z没有写,保持5;B保持30°,C保持90°;G1、G54、G21、F300继续有效。')+
table(['量','新的目标'],[['完整工件XYZ','(0,10,5)'],['完整角度B/C','30° / 90°'],['K的XYZ','(40,30,15)'],['C旋转后的中间点','(−30,40,15)'],['B倾转后的XYZ关节','(−18.481,40,27.990)']])+
p('完整程序在这一段后执行M2。真实WASM规划器跑到程序完成时,关节终点与上述逐字解析、逆解计算一致;不是只在纸上进行了三角函数演算。')+
box('省略不代表零','如果你把省略的Z读成0,会得到不同的点。把省略的B/C读成0更会改变整套空间方向。初学时可以把每行补成“完整目标表”,即使程序文本只写了两个字。'),
'程序短,不代表有效状态少;把沿用状态补全后再计算。','gcode-evidence.json:最终joint=(-18.480762,40,27.990381,30,90)。')
rows=[]
for row in DATA['rows'][4:9]:
rows.append([row['block'].split()[0],xyz(row['work_xyz']),xyz([row['canonical'][a] for a in 'xyz']),f"{row['joints'][3]:g} / {row['joints'][4]:g}",xyz(row['joints'])])
page('第7章 · 全过程汇总','49 把五组数字同时放在一张表里',
table(['段号','工件XYZ','K输入XYZ','B/C(°)','关节XYZ'],rows,'compact')+
p('比较N50与N60:工件XYZ和K的XYZ都不变,关节随C改变。比较N60与N70:在C=90°时沿工件X增加10,反映为机床Y增加10。比较N70与N80:工件点相同,只倾转B,X/Z关节补偿。')+
fig('continuous-joints','N60整段C从0到90°:图中是XYZ关节变化,不是程序工件XYZ变化。',275)+
p('图中的横轴是C角度,不是时间。实际时间还受进给、加减速和机床约束影响,不能用图上每一度的等距刻度推断匀速运行。','small'),
'一张正确的记录表,应区分程序坐标、运动学输入、角度和物理关节。')
page('第7章 · 再切回直接模式','50 N100/N110:同一刀尖位置,坐标解释改变',
code('N100 M429\nN110 M428')+
p('N90末端关节固定为(−18.481,40,27.990,30°,90°)。M429把解释切到Identity,不能因此让这些关节无目标地跳动。新模式下基础XYZ与直线关节直接对应。')+
table(['量','M429后','再M428后'],[['物理XYZ关节','(−18.481,40,27.990)','保持相同'],['基础K的XYZ','(−18.481,40,27.990)','(40,30,15)'],['当前G54相对XYZ显示','(−58.481,20,17.990)','(0,10,5)'],['实际刀尖位置','不动','不动']],'compact')+
p('在Identity下,当前软件坐标不再通过RTCP映射到转动后的零件局部坐标架。因此该模式的相对XYZ读数不能继续当作刚才物理工件架W中的同一点坐标来解释。')+
box('后续指令必须按新模式读','切换本身不动,不代表切换后再发“X0”也不动。相同字值在不同解释模式中可能指向完全不同的机械目标。'),
'切换时固定关节、重新解释坐标;不要为了保持旧XYZ数字而挪动机床。','WASM selectPhysical与解释器同步逐段实测。')
page('第8章 · 中间轨迹','51 RTCP要在运动过程中持续计算',
fig('arc-chord','图为半径100mm的零偏置例:两种方法终点相同,中途却相差29.289mm。',280)+
p('假设工件点绕C轴转90°。正确跟随时,刀尖走该点在机床空间里的圆弧。若只求首末关节位置,然后不顾几何地把X/Y用直线连接,可能走成弦线。')+
p('所以控制器不能只在段末才补偿。它需要在连续采样中使用当前插补出的XYZ和角度,得到相应关节值。各关节的变化不一定是线性的,也不必在每个时刻都非零。')+
box('五轴联动的正确含义','系统具备同时协调五个自由度的能力,不意味着每条指令、每个时刻五根轴都必须运动。特殊几何下某些轴不动,可能正是正确答案。'),
'只看“最后有没有对上”不足以检验RTCP。')
page('第8章 · 姿态与刀轴','52 RTCP不自动决定刀轴该朝哪里',
fig('orientation','双转台中主轴在机床里可保持竖直,但相对于工件的刀轴方向会改变。',250)+
p('本课机床不摆动主轴头。A/B、C带动工件后,主轴方向相对于工件发生变化。程序或CAM负责给出希望的角度/姿态,RTCP负责按这些角度保持所需刀尖位置关系。')+
p('RTCP不会仅凭一个曲面网格就自动知道应当以什么刀轴角加工,也不会自动保证刀轴始终垂直曲面。后者还涉及刀位规划、刀具几何、工艺策略和碰撞检查。')+
table(['给定条件','仍需解决的问题'],[['知道一个刀尖点','刀轴方向可能有多种选择。'],['知道点和A/B、C','还需用正确机床几何求关节。'],['关节在限位内','还不能证明刀具/夹具/机床无碰撞。']])+
p('工件坐标方向、刀具方向、表面法向是三个概念。某些工艺要求它们满足特定关系,但它们不会天然全部重合。'),
'RTCP执行指定的几何关系,不能替代CAM工艺与碰撞规划。')
page('第8章 · 三维显示','53 预览画面中的三个对象要分开看',
table(['显示对象','代表什么','常见误判'],[['机床网格','结构外形及由关节驱动的部件位姿。','把网格缩放误当成运动学标定。'],['工件/程序路径','表达程序几何,通常附着在工件分支。','把摄像机转动当作工件坐标改变。'],['刀尖/实时轨迹','显示当前采样的受控点或路径。','只看路径颜色,不核对实际关节/参考系。']])+
p('正确结构中,X/Y/Z移动刀具侧,A/B→C移动工件侧。路径必须与其定义的参考系一致。如果C错误地挂到Z下面,XYZ就会把工件也带走;再正确的RTCP数字也无法弥补错误的显示树。')+
p('导入GLB后修改中心/偏置,应保留模型并同步有效URDF与WASM。参数改变可以使部件相对位置改变,但不应该悄悄替换成另一台内置机床。')+
box('显示不是第二套控制器','不要在Three.js里编写另一套补偿公式把刀尖“画到看起来正确的位置”。关节应由真实运动学算出,图形只按同一结构进行刚体投影。')+
p('外观网格中的铸件、支架形状不一定随参数自动重建,模型校正也不等于通过了真实碰撞/刚度/制造尺寸验收。','small'),
'验证要同时看程序参考、关节数据和场景层级,不能只凭视觉效果。')
page('第8章 · 参数准备','54 准备数据时,按三层顺序核对',
h('第一层:机床自身')+p('确认结构是AC还是BC、每个节点的父级、各轴正方向、回零基准、倾转中心与轴间偏置。轴向或结构类型错误时,先修复模型/配置,不用G54去“抵消”它。')+
h('第二层:刀具与受控点')+p('确认主轴参考点、刀尖节点、刀长定义和补偿状态。把换刀之后的受控点映射建立正确,再判断实际加工位置。')+
h('第三层:零件装夹与编程')+p('确认CAD/CAM原点、工件方向、G54等偏置、当前单位、绝对/增量方式和进给模式。用多个已知点、多个姿态核对,避免只在零角度看起来对。')+
table(['检查方法','能发现的问题'],[['零角度检查已知点','基本零点/长度/单位错误。'],['小角度分别转A/B和C','轴向、正负号、父子顺序问题。'],['不同角度反复定位同一点','旋转中心、偏置与刀尖标定问题。'],['重复换模式但不发运动','坐标同步错误导致的无故跳变。']])+
box('不要用单点验证全部参数','错误的结构参数可以在某一点被工件偏置抵消,却在其他点或其他角度暴露。标定必须覆盖足够的空间位置与姿态。'),
'先机床,再刀具,最后工件;分层定位错误比同时改所有参数可靠。')
page('第9章 · 课堂实验','55 实验一:观察“转动后的坐标方向”',
ol('进入仿真,关机且空闲时导入AC或BC桁架示例,查看当前几何参数。','完成开机和回零,在Identity下把刀尖放到离C轴线有一定距离的位置,避免特殊点导致XYZ补偿不明显。','停止运动,选择M428。记录关节与物理刀尖没有瞬移,同时记录当前XYZ显示可能变化。','旋转C一个小角度并停止。保持当前姿态,再做一个小的编程X增量,观察实际哪几根直线关节变化。','比较工件方向与机床导轨方向:导轨没转,XYZ合成了工件方向。','先停止,再切回M429,重复小幅直接轴Jog作为对照。')+
p('项目默认示例有非零中心/偏置,所以不要拿本章零偏置算例的目标关节值逐项硬比。对默认机床,正确比较的是“同一配置下的运动学与模型是否一致”。')+
box('每组记录四列','当前模式;编程/显示XYZ与角度;J0…J4;刀尖相对工件标记点是否保持。只截图一个角度,不足以说明全部行为。'),
'实验目标是理解参考系关系,不是让学生背住某一组关节数字。')
page('第9章 · 课堂实验','56 实验二:G54与G90/G91的逐行记录',
p('本实验适合教师准备的专用仿真配置:零中心、零轴间偏置、零刀长差,BC结构,回零后从已知状态开始。不得直接在真实机床套用。')+
ol('设置G21/G90/G94,写入G54偏置(40,20,10),选择G54。','执行M428,记录此时关节不变。','执行N50到N90,每段等待运动结束,再记录工件坐标、K输入、关节。','把结果与第49节表格对照,重点检查N60和N70。','尝试只选择G55,不发轴字;观察读数变化但关节保持。','恢复已知模式与偏置后再重复,检查结果是否一致。')+
table(['测试点','应该看到'],[['只选择/设置参考','没有自动的定位运动。'],['N60 C90','程序XYZ保持,关节XYZ变化。'],['N70 G91 X10','W的X加10;本例关节Y加10。'],['N80恢复G90','X20是绝对目标,不能再加20。']])+
p('本讲义提供完整验证数据而非截图推测。如果现场结果不同,先核对几何与模态条件,不要立刻断言某根轴接错。','small'),
'逐行记录让隐含模态变成看得见的数据。')
page('第9章 · 课堂实验','57 实验三:原点移动,程序几何不改',
p('目标:理解同一段零件程序如何适应不同装夹位置。先保持角度固定,避免初学者同时处理太多变量;再在RTCP下加入姿态对照。')+
ol('为G54设置第一套零位偏置,为G55设置第二套偏置,保持初始方向相同。','选G54运行一条很短、经过检查的定位段,记录目标。','只选G55,不发运动字,确认执行轴不会自动移动。','再给同样的工件XYZ目标,观察物理目标随配准变化。','停止后在适当角度下重复;说明原点和方向怎样一起进入RTCP。')+
box('配准变化不是机床标定变化','移动工件装夹时通常更新工件设置;不要为了让两个工件都能加工而随意改动倾转轴物理中心。机床结构没有变,工件放置关系变了。')+
p('若工件不仅平移,还在夹具上旋转,需要另外确定初始方向。仅改变G54的XYZ平移不会自动把一条斜边校正成你期望的+X方向。')+
p('作业:让学生画出两套工件原点和同一局部点,先预测实际目标变化,再用仿真核对。解释预测为何成立比得到一次正确数字更重要。'),
'工件坐标把零件几何与装夹位置分开,但方向配准仍不可省略。')
page('第9章 · 排错','58 位置不对时,从哪一层开始查?',
table(['现象','优先检查','不要马上做什么'],[['全程序平移一块','G54/G55、附加偏置、装夹原点。','先别改所有转台参数。'],['距离约差25.4倍','G20/G21和数据单位。','别靠缩放模型掩盖。'],['小动作不断累加','G90/G91是否残留。','别把每次误差当随机漂移。'],['零角度对,倾转就偏','中心/偏置、刀长、正方向、结构链。','别只在一个角度改G54。'],['只开RTCP却不跟点','实际运动模式、目标参考、父子关系。','别只看按钮颜色。'],['切模式机床瞬移','是否固定关节并同步坐标。','别冻结画面掩盖。'],['Connection ERROR','首个Worker/协议/运动错误。','别把后续sequence_gap当成唯一根因。']],'compact')+
p('查看第一个失败原因通常比反复点击更有效。显示错误可能是运动计算错误的后果,也可能是模型投影错误;两类问题应通过关节数据与物理结构分别判断。')+
box('一条诊断原则','保持一层已知正确,只改变一个待验证因素。否则多个错误可能暂时互相抵消,无法解释修复是否真正有效。'),
'先找最早的因果点,不要只处理最后出现的提示。')
page('第9章 · 限位与边界','59 RTCP跟得上,还必须到得了',
p('工件XYZ看起来没有变化,不等于机械关节没有运动。转台角度改变时,为保持同一工件点,某根XYZ直线关节可能需要大幅补偿,最终触及行程或速度限制。')+
table(['约束','检查对象'],[['编程/坐标限位','控制器定义的坐标允许范围。'],['关节限位','各机械轴实际能够达到的范围。'],['速度/加速度','每个执行轴的动态能力。'],['碰撞','刀具、工件、夹具、转台和机床实体间的空间关系。']])+
p('本项目曾出现BC坐标范围较大,但B关节上限只有50°的情况。连续Jog越过该物理约束后,运动计算失败,又触发事件时钟错误。现已区分限位阻止与连接故障,并允许停止后反向退出。')+
box('不要把仿真边界扩大成硬件声明','项目对RTCP耦合关节越界采用最后有效伺服样本停止。它不等同于真实设备的全路径预测减速、安全PLC、碰撞检测或硬件急停系统。')+
p('课堂先用小位移、小角度、低速度、空载模型理解概念;实际机床的操作与保护必须使用其正式流程。','small'),
'RTCP正确解决几何映射,不自动取消行程、动力学和碰撞约束。')
page('第10章 · 常见问答','60 初学者最容易问错的八个问题',
table(['问题','简明回答'],[['工件坐标原点一定是转台中心吗?','不是。零件原点由工艺选,转台中心是结构标定。'],['工件坐标的零点会变化吗?','自身读数恒为0;在机床空间的位置可随角度变化。'],['只选G54能让刀尖跟着转台吗?','不能。还需要正确结构、姿态与RTCP运动学。'],['写X10为什么Y轴动?','X可能是工件方向,需由固定导轨合成。'],['没写XYZ为什么XYZ动?','程序点保持,但转台姿态变化需要补偿。'],['RTCP时所有轴必须一直动吗?','不必,取决于轨迹和当前几何。'],['H3就是3mm刀长吗?','不是,完整LinuxCNC里H是刀补条目索引。'],['界面“Machine XYZ”就是J0–J2吗?','不能默认;非线性运动学中需核对显示定义。']],'compact')+
p('再遇到一个看似矛盾的说法,先问它说的是哪个参考系、哪个受控点和哪个控制模式。许多矛盾只是省略了这些条件。'),
'补全条件后再下结论,是理解五轴的基本方法。')
page('第10章 · 练习','61 练习A:原点、模式和地址字',
ol('工件原点在零位K中为(40,20,10),方向对齐。W=(10,0,5)对应的K是多少?','当前位置W的X=10。分别执行G90 X20和G91 X20,各得到什么W终点?','单独执行G54会不会自动走到工件零点?若同一行是G54 G1 X0呢?','当前位置K=(70,30,20),希望此刻W=(5,0,0)。在无附加旋转/补偿的教学条件下,L20算出的偏置o是多少?','T3、H3、S12000、F600分别属于哪种数据?哪些数字不能被直接当作毫米距离?','工件原点测对了,但长边相对机床X倾斜20°。只改G54的X/Y/Z平移,是否足以纠正方向?')+
box('答题要求','每题至少写一句解释。不要只交三个数字;需说明参考系、模式和假设条件。可以画原点与方向箭头辅助推理。'),
'这些题不需要矩阵,关键是把词语和参考对象对应起来。')
page('第10章 · 练习','62 练习B:从工件点推到物理关节',
ol('零中心/偏置/刀长差,B=0、C=90°,o=(40,20,10),W=(10,0,5)。求XYZ关节和工件原点的空间位置。','保持上题姿态,执行G91 X10。哪根直线关节增加多少?','从N70末端执行G90 X20 Y0 Z5 B30 C90,工件点是否变化?为什么X/Z关节仍会变?','M429后XYZ读数变化,关节和刀尖却不动,是正常还是故障?如果反过来XYZ不变、关节跳动呢?','程序只改C,但XYZ没有动,一定说明RTCP坏了吗?给出一个可以正确不动的几何例子。','C轴错误挂在Z滑枕下,会造成什么显示/结构错误?能否通过开启RTCP修复?','为什么只校验首末关节位置,不足以证明整段RTCP正确?')+
p('扩展题:用你自己的一个工件原点和目标点,手算C=0°与90°的物理位置;再说明旋转中心改成非零后,哪一步需要修改。','small'),
'把中间层K写出来,可以明显减少漏加偏置和重复加偏置。')
page('第10章 · 参考答案','63 练习A答案',
ol('<b>K=(50,20,15)。</b>方向对齐、仅平移时,p_K=p_W+o。','<b>G90终点X=20;G91终点X=30。</b>前者是绝对目标,后者在当前10基础上加20。','<b>单独G54不定位。</b>G54 G1 X0同时含有运动目标,会结合当前坐标与模式形成轨迹。','<b>o=(65,30,20)。</b>当前位置K减去希望的W:(70,30,20)−(5,0,0)。','<b>T3为工具选择号,H3为刀补条目索引,S12000为主轴速度请求,F600由进给模式解释。</b>不能将这些数全当作mm。','<b>不够。</b>三个平移参数只改变原点,不能补偿初始方向旋转。需机械找正或使用受支持的坐标旋转/方向配准。')+
box('进一步检查','第4题的公式只在声明的简单条件下使用。存在旋转、刀补或非平凡运动学时,必须使用相应参考下的当前位置,而不是直接拿原始关节读数代入。'),
'答案的条件是答案的一部分,不能省略。')
page('第10章 · 参考答案','64 练习B答案',
ol('<b>XYZ关节=(−20,50,15),工件原点O_M=(−20,40,10)。</b>先求K=(50,20,15),再绕C90°。','<b>机床Y增加10mm。</b>当前工件+X就是机床+Y,新的关节为(−20,60,15)。','<b>工件点不变,仍是(20,0,5)。</b>B姿态改变,新的XYZ关节约为(−9.821,60,22.990)。','<b>坐标重新解释、关节保持是正常。</b>单纯切换却改变物理关节,一般说明同步/切换逻辑有问题。','<b>不一定。</b>点在C旋转轴线上且其他条件不变时,只转C可以不改变该点的机床空间位置。','<b>工件会错误地随XYZ链移动。</b>必须修复结构父子关系,开关RTCP不能替代正确拓扑。','<b>端点相同,中间路径仍可能不同。</b>例如圆弧与弦线;必须检查连续采样和相对工件轨迹。'),
'每个答案都可以画成“原点 + 方向 + 点”的几何关系。')
page('附录A · 术语','65 把常见名词翻译成容易理解的话',
table(['术语','初学者解释'],[['坐标系/参考系','放在某处、有指定方向和单位的一套尺子。'],['原点/零点','这套尺子的起点,不一定是电机回零位置。'],['工件偏置/WCS offset','让零件程序与当前装夹参考对齐的数据。'],['关节/joint','被控制的机械自由度及其位置。'],['正运动学/FK','由关节与结构计算对应坐标/受控点。'],['逆运动学/IK','由目标点、姿态和结构计算关节目标。'],['姿态/orientation','方向关系;本课由A/B、C指定。'],['TCP/RTCP','与刀尖目标、姿态变化补偿相关的功能;需核对控制器定义。'],['插补/interpolation','在段起点和终点间连续生成中间目标。'],['模态/modal','指令生效后持续保留,直到被同组设置替换。'],['配准/registration','建立设计坐标与实际装夹之间的对应关系。'],['同步/synchronization','让解释器、规划器和运动状态使用一致的当前参考。']],'compact'),
'看到新术语,先找它属于“几何、程序、机械、执行”哪一层。')
page('附录B · 可选数学','66 用最少公式描述完整坐标链',
p('以下公式供进阶阅读,不是初学者读前面章节的前提。向量用列向量,R表示正交旋转矩阵;长度统一为mm。')+
h('1. 初始工件配准')+eq('p_K = R₀ p_W + o')+
p('o给出工件原点在零位参考中的位置;R₀给出初始工件方向。本章实算R₀=I,因此只做平移相加。')+
h('2. 当前双转台姿态与几何,L=0')+eq('r_M = P + D + R_tilt [ R_C (p_K − P) − D ]')+
p('AC取R_tilt=R_X(A),BC取R_tilt=R_Y(B)。因此可写为r_M=O_M+E_MW p_W,其中:')+
eq('E_MW = R_tilt R_C R₀<br>O_M = P + D + R_tilt [ R_C(o − P) − D ]')+
p('这里r_M是本课零刀长差模型的目标空间位置,并对应直线关节目标。加入刀具标定后还需从刀尖求主轴/执行参考;任意机床结构不能直接套用这一特定式子。','small'),
'原点项O_M与方向项E_MW同时随姿态变化,分开写有助于理解。')
page('附录B · 可选数学','67 反过来:从当前物理位置求工件坐标',
p('在姿态与几何已知、没有奇异歧义且标定一致时,前页r_M=O_M+E_MW p_W可以反算:')+
eq('p_W = E_MWᵀ (r_M − O_M)')+
p('先从当前空间位置减去当前工件原点,得到相对位移;再用逆旋转,把这个位移投回工件三个方向。正交旋转矩阵的逆等于转置,所以这里写上标T。')+
p('这正是“坐标数值可以变,而物理点不变”的数学基础。同一r_M在不同O/E下有不同p_W。模式切换、工件系选择或重新设置参考时,必须明确变了哪一层。')+
h('本项目刀长差的特殊约定')+eq('D_L = D + L·e_z<br>q_XYZ = P + D_L + R_tilt [ R_C(p_K − P) − D_L ]')+
p('本项目原生TRT的L是master-tool刀长差约定。这个式子返回关节目标,不是任意GLB刀尖网格的世界位置。完整工具标定、工件架与刀尖显示须遵循同一个约定;其他控制器应按其文档重新建立链路。','small'),
'先减原点、再投影方向;关节到刀尖之间还可能存在刀具参考变换。','本地trtfuncs.c、imported-machine.ts;主实验L=0。')
page('附录C · 软件实验范围','68 这份讲义验证了什么,没有声称什么?',
table(['内容','本次证据'],[['G10 L2、G54/G55平移、G90/G91','实际项目WASM逐段解析,核对规范目标。'],['G20长度/G94直线进给换算','X1→25.4mm,F2→50.8mm/min实测。'],['G10 L20位置重定义','偏置计算及后续目标实测。'],['RTCP BC例子的目标关节','真实WASM逆解计算;关键数值强断言。'],['N10–N90完整运动','真实Session/解释器/TP执行完成,终点与逐行结果一致。'],['M429/M428保持物理关节','原生切换接口+坐标同步逐段验证。'],['G52/G92、任意方向变换、完整刀表与所有F模式','依据官方语义讲解,不冒充Web切片已全面实现。']],'compact')+
p('项目调用实际WASM并不能自动证明它覆盖了完整LinuxCNC。特别是本地解释器适配层属于功能切片,不能把接受某个字母或设置内部字段当作完整语义和工艺正确性证据。')+
box('对照两层资料','学习通用指令时查官方文档;复现本课数字时查同目录gcode-evidence.json和具体测试条件。两者各自解决不同问题。'),
'严谨的教学需要同时给出结果和结果成立的范围。')
page('附录D · 来源与复现','69 官方资料、验证数据与自学路径',
'<div class="references">'+
p('<b>[S1]</b> <a href="https://linuxcnc.org/docs/html/motion/5-axis-kinematics.html">LinuxCNC · 5-Axis Kinematics</a><br>用于核对AC/BC机构、前后向变换、旋转中心、偏置与刀具参考。')+
p('<b>[S2]</b> <a href="https://linuxcnc.org/docs/html/gcode/coordinates.html">LinuxCNC · Coordinate Systems</a><br>用于核对机床/工件坐标、G54–G59.3及附加偏置。')+
p('<b>[S3]</b> <a href="https://linuxcnc.org/docs/html/gcode/machining-center.html">LinuxCNC · CNC Machine Overview</a><br>轴命名、受控点、单位、旋转与协调运动。')+
p('<b>[S4]</b> <a href="https://linuxcnc.org/docs/html/gcode/g-code.html">LinuxCNC · G-Codes</a><br>G10、G20/G21、G43、G53、G90/G91、圆弧/进给等指令语义。')+
p('<b>[S5]</b> <a href="https://linuxcnc.org/docs/html/gcode/overview.html">LinuxCNC · G-code Overview</a><br>程序段、地址字、表达式、模态组及执行顺序。')+
p('<b>[S6]</b> <a href="https://linuxcnc.org/docs/html/motion/switchkins.html">LinuxCNC · Switchable Kinematics</a><br>切换模式及解释器/运动同步。')+'</div>'+
p('本地依据:sim_interpreter.c、sim_program.c、trtfuncs.c、sim-session.mjs,以及428remap.ngc/429remap.ngc。图示为原创教学图和项目原创GLB渲染。官方页面核对日期:2026-09-18。','small')+
box('随讲义提供','handbook.html为可编辑源稿,outline.json为逐页目录,gcode-evidence.json保存逐字结果与完整轨迹,verification/保存版面/PDF核验。原31页投影片保留作课堂概要,本讲义作为可独立阅读的完整版。'),
'最后记住:先定义原点和方向,再解释程序数值,最后由运动学求出各轴运动。')
chapter_starts=[]
for i,item in enumerate(PAGES):
group=item['chapter'].split(' · ')[0]
if not chapter_starts or chapter_starts[-1][0]!=group:
chapter_starts.append((group,i+1))
chapter_names={'第1章':'坐标与机床基础','第2章':'机床、工件与中间参考','第3章':'原点和方向如何确定','第4章':'G54、G10与其他偏置','第5章':'RTCP的点、方向与标定','第6章':'G代码逐字与模态解析','第7章':'完整程序逐行实算','第8章':'连续轨迹、刀轴与显示','第9章':'课堂实验、限位与排错','第10章':'问答、练习与答案','附录A':'术语表','附录B':'可选数学推导','附录C':'软件验证范围','附录D':'资料来源与复现'}
toc_rows=[[f'<a href="#page-{number}">{name} {chapter_names[name]}</a>',str(number)] for name,number in chapter_starts if name in chapter_names]
PAGES[2]['body']=p('本书从基础坐标开始,逐步建立程序与关节之间的计算链。PDF目录书签可直接跳转章节。节号不是PDF页码;前面另有封面和导读。')+table(['章节','PDF起始页'],toc_rows,'compact')+p('建议每次学习1–2章,完成相应小实验后再继续。数值实算章建议边读边写出W、K、角度、J四列。','small')
CSS=r'''
@font-face { font-family:HandbookCJK; src:url("assets/handbook-regular.otf") format("opentype"); font-weight:400; }
@font-face { font-family:HandbookCJK; src:url("assets/handbook-bold.otf") format("opentype"); font-weight:700; }
@page { size:A4; margin:0; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; background:#e1e7ea; color:#24313a; font-family:HandbookCJK,"Noto Sans CJK SC",sans-serif; }
.sheet { width:793.7px; height:1122.52px; padding:38px 46px 26px; position:relative; background:#fff; overflow:hidden; break-after:page; page-break-after:always; }
.sheet:last-child { break-after:auto; page-break-after:auto; }
.chapter { font-size:13px; line-height:1.5; color:#00877b; margin-bottom:10px; font-weight:600; }
h1 { font-size:27px; line-height:1.4; margin:0 0 21px; letter-spacing:0; }
h2 { font-size:19px; line-height:1.5; color:#00877b; margin:18px 0 10px; }
.body { height:863px; overflow:hidden; }
p,li { font-size:17px; line-height:1.85; }
p { margin:0 0 16px; text-align:justify; }
ul,ol { margin:8px 0 18px; padding-left:26px; }
li { margin-bottom:11px; padding-left:3px; }
b { font-weight:700; }
.subtitle { font-size:25px; color:#00877b; margin-bottom:24px; text-align:left; }
.small { font-size:14px; line-height:1.8; color:#5e6c75; }
figure { margin:15px 0 20px; }
figure img { width:100%; display:block; object-fit:contain; }
figcaption { font-size:12px; line-height:1.7; color:#61717b; text-align:center; margin-top:6px; }
table { width:100%; border-collapse:collapse; font-size:15px; line-height:1.7; margin:14px 0 20px; }
th { padding:10px 11px; text-align:left; background:#eaf1f4; color:#254956; border-bottom:2px solid #9cb9c5; font-weight:600; }
td { padding:10px 11px; vertical-align:top; border-bottom:1px solid #dbe5e9; }
td:first-child { color:#2563b8; font-weight:600; }
.compact { font-size:14px; line-height:1.65; }
.compact th,.compact td { padding:8px 9px; }
.equation { font-size:20px; line-height:1.8; padding:14px 16px; background:#f0f5f7; text-align:center; margin:16px 0 22px; overflow-wrap:anywhere; }
pre { font-family:"DejaVu Sans Mono",HandbookCJK,monospace; font-size:15px; line-height:1.85; white-space:pre-wrap; overflow-wrap:anywhere; background:#f0f4f6; border-left:4px solid #2563b8; padding:15px 18px; margin:15px 0 21px; }
aside { margin:17px 0 20px; padding:13px 16px; border-left:4px solid #d45549; background:#f7f4f2; }
aside>b { display:block; font-size:16px; color:#a84840; margin-bottom:7px; }
aside p { font-size:15px; line-height:1.8; margin:0; }
.key { position:absolute; left:46px; right:46px; bottom:61px; padding:10px 13px; border-left:4px solid #00877b; background:#eaf5f2; font-size:15px; line-height:1.65; }
footer { position:absolute; left:46px; right:46px; bottom:22px; display:flex; gap:15px; justify-content:space-between; border-top:1px solid #dbe5e9; padding-top:8px; color:#68777f; font-size:10px; line-height:1.6; }
footer span:first-child { max-width:620px; }
.folio { color:#00877b; white-space:nowrap; font-size:13px; }
a { color:#176da5; text-decoration:none; }
.references p { font-size:15px; line-height:1.8; margin-bottom:17px; }
@media screen { .sheet { margin:20px auto; box-shadow:0 2px 14px #0002; } }
@media print { html,body { background:#fff; } .sheet { margin:0; box-shadow:none; } * { print-color-adjust:exact; -webkit-print-color-adjust:exact; } }
'''
sections=[]
for index,item in enumerate(PAGES,1):
sections.append(f'<section class="sheet" id="page-{index}" data-page="{index}"><div class="chapter">{item["chapter"]}</div><h1>{item["title"]}</h1><div class="body">{item["body"]}</div><div class="key">{item["key"]}</div><footer><span>{item["refs"]}</span><span class="folio">{index:02d} / {len(PAGES):02d}</span></footer></section>')
doc='<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>五轴RTCP与工件坐标:初学者完整讲义</title><style>'+CSS+'</style></head><body>'+''.join(sections)+'</body></html>'
(OUT/'handbook.html').write_text(doc)
(OUT/'outline.json').write_text(json.dumps([{'page':i+1,'chapter':x['chapter'],'title':x['title']} for i,x in enumerate(PAGES)],ensure_ascii=False,indent=2)+'\n')
print('Generated',len(PAGES),'A4 pages:',OUT/'handbook.html')

View File

@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import createModule from '../build-wasm/linuxcnc-sim-core.js';
import { WasmXyzacKinematics } from '../web/worker/wasm-xyzac-kinematics.mjs';
const out = new URL('../docs/teaching/rtcp-five-axis/', import.meta.url);
await mkdir(new URL('assets/', out), { recursive: true });
const wasm = await readFile(new URL('../build-wasm/linuxcnc-sim-core.wasm', import.meta.url));
const core = await createModule({ wasmBinary: wasm });
const basePose = { x: 100, y: 0, z: 20, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 };
const cases = [];
const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`);
for (const coordinates of ['XYZAC', 'XYZBC']) {
const k = new WasmXyzacKinematics(core, { coordinates, limits: Object.fromEntries([...coordinates.toLowerCase()].map((a) => [a, [-100000, 100000]])) });
const samples = [];
for (const [tilt, c] of [[0, 0], [0, 45], [0, 90], [30, 0], [30, 45], [30, 90]]) {
const p = { ...basePose, c, [coordinates[3].toLowerCase()]: tilt };
const ik = k.inverseMode(1, p); assert.equal(ik.status, 'verified');
k.selectPhysical(1, ik.joints);
const fk = k.forward(ik.joints); assert.equal(fk.status, 'verified');
for (const axis of ['x', 'y', 'z']) close(fk.pose[axis], p[axis]);
samples.push({ tilt, c, programmed: p, joints: ik.joints, forward: fk.pose });
}
close(samples[2].joints[0], 0); close(samples[2].joints[1], 100); close(samples[2].joints[2], 20);
const direction = k.inverseMode(1, { ...basePose, c: 45, x: 110 }).joints.map((v, i) => v - samples[1].joints[i]);
close(direction[0], 10 / Math.sqrt(2)); close(direction[1], 10 / Math.sqrt(2));
cases.push({ coordinates, samples, xw_increment_10_at_c45: direction });
k.destroy();
}
const offsets = { x_rot_point: -18, y_rot_point: 14, z_rot_point: 12, x_offset: 22, y_offset: 0, z_offset: -40, tool_offset: 0 };
const k = new WasmXyzacKinematics(core, { coordinates: 'XYZBC', ...offsets, limits: Object.fromEntries(['x', 'y', 'z', 'b', 'c'].map((a) => [a, [-100000, 100000]])) });
const physical = [0, 0, 44.5858, 4.8675, 179.88];
const tcp = k.selectPhysical(1, physical); const tcpPose = { ...tcp.pose };
const identity = k.selectPhysical(0, physical);
physical.forEach((value, i) => { close(tcp.joints[i], value); close(identity.joints[i], value); });
const report = {
report_id: 'rtcp-teaching-numerics-v1', status: 'verified',
wasm_sha256: createHash('sha256').update(wasm).digest('hex'),
convention: 'Native AC: Rx(A) Rz(C); BC: Ry(B) Rz(C). Main teaching examples P=D=tool_offset=0, mm/deg.',
cases,
switch_example: { offsets, physical, tcp_pose: tcpPose, identity_pose: identity.pose },
chord_midpoint: { compensated: [100 / Math.sqrt(2), 100 / Math.sqrt(2), 20], uncompensated_joint_interpolation: [50, 50, 20], distance: 100 - 50 * Math.sqrt(2) },
};
k.destroy();
await writeFile(new URL('numeric-evidence.json', out), JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, poses: 12, fk_ik_roundtrips: 12, source: report.wasm_sha256 }));

View File

@@ -0,0 +1,167 @@
"""Original teaching diagrams. Requires matplotlib; no application code changes."""
import math
from pathlib import Path
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib import font_manager
from matplotlib.patches import Circle, FancyArrowPatch, Rectangle
import numpy as np
from PIL import Image, ImageChops
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'docs/teaching/rtcp-five-axis/assets'
OUT.mkdir(parents=True, exist_ok=True)
font_manager.fontManager.addfont('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc')
plt.rcParams.update({'font.family': 'Noto Sans CJK JP', 'font.size': 15, 'axes.unicode_minus': False})
BLUE, TEAL, RED, GRAY, INK = '#2563b8', '#00877b', '#d45549', '#8a969f', '#23303a'
def save(fig, name):
path = OUT / f'{name}.png'
fig.savefig(path, dpi=190, facecolor='white', bbox_inches='tight', pad_inches=.18)
plt.close(fig)
if name == 'bc-frame':
image = Image.open(path).convert('RGB')
ink = ImageChops.difference(image, Image.new('RGB', image.size, 'white')).convert('L').point(lambda v: 255 if v > 8 else 0)
left, top, right, bottom = ink.getbbox()
image.crop((max(0,left-25),max(0,top-25),min(image.width,right+25),min(image.height,bottom+25))).save(path)
def axis2(ax, limit=125):
ax.set(xlim=(-30, limit), ylim=(-30, limit), aspect='equal')
ax.axis('off')
ax.annotate('', (limit-5, 0), (-22, 0), arrowprops={'arrowstyle': '-|>', 'color': BLUE, 'lw': 2})
ax.annotate('', (0, limit-5), (0, -22), arrowprops={'arrowstyle': '-|>', 'color': BLUE, 'lw': 2})
ax.text(limit-8, -12, '$X_M$', color=BLUE, ha='right')
ax.text(-12, limit-9, '$Y_M$', color=BLUE, va='top')
ax.text(-10, -13, 'O', color=INK)
def point(ax, x, y, label, color, offset=(7, 7), marker='o'):
ax.scatter([x], [y], s=150, c=color, marker=marker, zorder=5)
ax.annotate(label, (x, y), xytext=offset, textcoords='offset points', color=color, fontsize=14)
def arc(ax, radius, end=90):
angles = np.linspace(0, np.deg2rad(end), 101)
ax.plot(radius*np.cos(angles), radius*np.sin(angles), color=TEAL, lw=2, ls='--')
# The same marked workpiece point, before and after turning the C table.
for follow in [False, True]:
fig, axes = plt.subplots(1, 2, figsize=(10, 4.3))
for ax in axes: axis2(ax); ax.add_patch(Circle((0, 0), 100, fill=False, ec='#d8e1e6', lw=2))
axes[0].set_title('起点:C = 0°', pad=10, color=INK)
point(axes[0], 100, 0, '工件标记点 + 刀尖', RED, (-138, 17), '*')
axes[0].plot([0, 100], [0, 0], color=TEAL, lw=4)
axes[1].set_title('终点:C = 90°', pad=10, color=INK)
arc(axes[1], 100)
axes[1].plot([0, 0], [0, 100], color=TEAL, lw=4)
point(axes[1], 0, 100, '同一个工件点', TEAL, (12, -3), '*')
if follow:
axes[1].scatter([0], [100], s=380, facecolors='none', edgecolors=RED, linewidths=3)
axes[1].text(40, 45, '刀尖由 XYZ 移到这里\n相对工件仍是同一点', color=RED, ha='center')
else:
point(axes[1], 100, 0, '刀尖留在原位置', RED, (-125, -22))
axes[1].plot([0, 100], [100, 0], color=RED, lw=1.6, ls=':')
axes[1].text(43, 52, '相对位置改变', color=RED, rotation=-45, ha='center')
fig.subplots_adjust(wspace=.22)
save(fig, 'tcp-follow' if follow else 'identity-no-follow')
# Rotated workpiece axes while the machine axes stay fixed.
fig, ax = plt.subplots(figsize=(6.2, 5.5)); axis2(ax, 150)
origin = np.array([60., 45.]); theta = math.pi/4
u, v = np.array([math.cos(theta), math.sin(theta)]), np.array([-math.sin(theta), math.cos(theta)])
corners = [origin+u*a+v*b for a,b in [(-22,-22),(32,-22),(32,32),(-22,32),(-22,-22)]]
ax.fill(*np.array(corners).T, color='#e3f3ef', zorder=0)
for direction, label, offset in [(u, '$X_W$', (3,0)), (v, '$Y_W$', (-12,0))]:
end = origin+direction*70
ax.annotate('', end, origin, arrowprops={'arrowstyle':'-|>', 'color':TEAL,'lw':3})
ax.annotate(label,end,xytext=offset,textcoords='offset points',color=TEAL)
start = origin+u*13; end = start+u*40
ax.annotate('',end,start,arrowprops={'arrowstyle':'-|>','color':RED,'lw':4})
ax.plot([start[0],end[0],end[0]],[start[1],start[1],end[1]],ls=':',color=RED,lw=2)
ax.text(78,49,'+7.071',color=RED,fontsize=13)
ax.text(107,75,'+7.071',color=RED,fontsize=13)
ax.text(22,126,'C = 45°:工件 +X 方向倾斜',color=INK,fontsize=14)
ax.text(42,21,'W 原点',color=TEAL,fontsize=13)
save(fig,'rotated-direction')
# A point on a tilting BC trunnion; explicit right-hand matrix convention.
fig = plt.figure(figsize=(7.8,5.9)); ax=fig.add_subplot(111,projection='3d')
b,c=np.deg2rad([30,45]); ry=np.array([[np.cos(b),0,np.sin(b)],[0,1,0],[-np.sin(b),0,np.cos(b)]])
rz=np.array([[np.cos(c),-np.sin(c),0],[np.sin(c),np.cos(c),0],[0,0,1]])
r=ry@rz; p=np.array([100,0,20]); q=r@p
for idx,label in enumerate(['$X_M$','$Y_M$','$Z_M$']):
e=np.eye(3)[idx]*110;ax.quiver(0,0,0,*e,color=BLUE,arrow_length_ratio=.07,lw=1.5);ax.text(*e,label,color=BLUE,fontsize=13)
for idx,label in enumerate(['$X_W$','$Y_W$','$Z_W$']):
e=r[:,idx]*75;ax.quiver(0,0,0,*e,color=TEAL,arrow_length_ratio=.09,lw=2.5);ax.text(*(e*1.13),label,color=TEAL,fontsize=15)
phi=np.linspace(0,2*np.pi,150);ring=r@np.array([100*np.cos(phi),100*np.sin(phi),np.zeros(len(phi))]);ax.plot(*ring,color=TEAL,alpha=.45)
ax.scatter(*q,color=RED,s=65);ax.plot([0,q[0]],[0,q[1]],[0,q[2]],color=RED,lw=2)
ax.text(*(q+np.array([0,0,9])),'刀尖目标',color=RED,fontsize=14)
ax.set(xlim=(-110,130),ylim=(-110,130),zlim=(-65,120));ax.set_box_aspect((1,1,.8));ax.view_init(elev=23,azim=-62);ax.set_axis_off()
save(fig,'bc-frame')
# Parent-child hierarchy, two separate mechanical branches.
fig,ax=plt.subplots(figsize=(11.2,4.5));ax.set(xlim=(0,11.2),ylim=(0,4.5));ax.axis('off')
def node(x,y,label,color=BLUE,w=1.6):
ax.add_patch(Rectangle((x,y),w,.65,fc='#f1f6f8',ec=color,lw=1.8))
ax.text(x+w/2,y+.325,label,ha='center',va='center',color=color,fontsize=15)
def link(a,b,color=GRAY):
style = 'arc3,rad=0' if abs(a[1]-b[1]) < 1e-9 else 'angle,angleA=0,angleB=90,rad=8'
ax.annotate('',b,a,arrowprops={'arrowstyle':'-|>','color':color,'lw':2,'connectionstyle':style})
node(.3,1.9,'机床底座',INK,1.8)
for x,label in [(3,'X 轴'),(5.5,'Y 轴'),(8,'Z 轴 + 刀具')]:node(x,3,label,BLUE,2.4 if x==8 else 1.6)
for x,label in [(3,'A 或 B 轴'),(6,'C 轴 + 工件')]:node(x,.8,label,TEAL,2.2)
link((2.1,2.2),(3,3.325),BLUE);link((4.6,3.325),(5.5,3.325),BLUE);link((7.1,3.325),(8,3.325),BLUE)
link((2.1,2.2),(3,1.125),TEAL);link((5.2,1.125),(6,1.125),TEAL)
ax.text(5.8,4.08,'刀具侧:直线运动链',ha='center',color=BLUE,fontsize=17)
ax.text(6,.22,'工件侧:倾转轴带着 C 转台一起运动',ha='center',color=TEAL,fontsize=16)
save(fig,'hierarchy')
# Offset pivot: the same 90-degree rotation produces different positions.
fig,axes=plt.subplots(1,2,figsize=(10.2,4.3))
for ax in axes: axis2(ax,145)
for ax, o, title in [(axes[0],np.array([0,0]),'旋转中心 O = (0, 0)'),(axes[1],np.array([30,20]),'旋转中心 O = (30, 20)')]:
point(ax,*o,'O',TEAL,(-12,-20));start=np.array([100,20]);end=o+np.array([-(start-o)[1],(start-o)[0]])
ax.plot([o[0],start[0]],[o[1],start[1]],color=GRAY,ls='--');ax.plot([o[0],end[0]],[o[1],end[1]],color=TEAL,lw=2)
point(ax,*start,'起点 (100, 20)',GRAY,(-94,-22));point(ax,*end,f'终点 ({end[0]}, {end[1]})',RED,(8,4))
ax.set_title(title,color=INK,pad=14,fontsize=16)
save(fig,'pivot-offset')
# Correct TCP arc versus independently interpolated joint chord.
fig,ax=plt.subplots(figsize=(6.5,5.2));axis2(ax,125)
t=np.linspace(0,np.pi/2,121);ax.plot(100*np.cos(t),100*np.sin(t),color=TEAL,lw=3)
ax.plot([100,0],[0,100],color=RED,lw=2.5)
q=100/np.sqrt(2);ax.scatter([q,50],[q,50],color=[TEAL,RED],s=95)
ax.plot([50,q],[50,q],color=INK,ls=':',lw=2)
ax.text(67,84,'RTCP:跟随工件点的圆弧',color=TEAL,ha='center',fontsize=14)
ax.text(35,33,'非 RTCP:独立轴线性插补',color=RED,ha='center',fontsize=14)
ax.annotate('中途偏离 29.289 mm',(61,61),xytext=(4,113),arrowprops={'arrowstyle':'->','color':INK},fontsize=14)
save(fig,'arc-chord')
# Tilted table and fixed vertical spindle: tool vector relative to workpiece changes.
fig,axes=plt.subplots(1,2,figsize=(10.2,4.1))
for ax,angle in zip(axes,[0,30]):
ax.set(xlim=(-70,70),ylim=(-40,105),aspect='equal');ax.axis('off')
phi=np.deg2rad(angle);u=np.array([np.cos(phi),-np.sin(phi)]);v=np.array([np.sin(phi),np.cos(phi)])
ends=np.array([-60*u,60*u]);ax.plot(*ends.T,color=TEAL,lw=7)
ax.add_patch(Rectangle((-9,58),18,35,fc='#b5c1c9',ec=INK,lw=1.5))
ax.plot([0,0],[6,58],color=RED,lw=5);ax.scatter([0],[6],color=RED,s=45)
ax.annotate('',(0,32),(0,78),arrowprops={'arrowstyle':'-|>','lw':2,'color':BLUE})
ax.annotate('',tuple(v*57),(0,0),arrowprops={'arrowstyle':'-|>','lw':2,'color':TEAL})
ax.text(18,80,'主轴方向固定',color=BLUE,fontsize=14);ax.text(*(v*62),'工件法向',color=TEAL,fontsize=14)
ax.set_title(f'B = {angle}°',color=INK,pad=10)
save(fig,'orientation')
# Tool length: preserving spindle reference alone does not preserve the tip.
fig,ax=plt.subplots(figsize=(8.2,4));ax.set(xlim=(0,10),ylim=(0,5));ax.axis('off')
for x,length,label in [(1.7,1.1,'短刀'),(5,2.0,'长刀,主轴未补偿'),(8.3,2.0,'长刀,主轴上移')]:
gauge=3.7 if x<7 else 4.6
ax.add_patch(Rectangle((x-.45,gauge),.9,.3,fc='#bac8ce',ec=INK))
ax.plot([x,x],[gauge-length,gauge],color=RED,lw=8)
ax.scatter([x],[gauge-length],color=RED,s=70)
ax.text(x,.28,label,ha='center',fontsize=14,color=INK)
ax.text(x+.24,gauge-length/2,'L',color=RED,fontsize=17)
ax.axhline(2.6,color=TEAL,ls='--',lw=2);ax.text(.1,2.77,'目标刀尖高度',color=TEAL,fontsize=13)
ax.annotate('',(8.9,4.6),(8.9,3.7),arrowprops={'arrowstyle':'-|>','color':BLUE,'lw':2});ax.text(9.05,4.1,'ΔL',color=BLUE)
save(fig,'tool-length')
print('Created 9 original diagrams in',OUT)

View File

@@ -0,0 +1,290 @@
"""Build the printable, editable Chinese RTCP teaching deck from verified data."""
import html
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'docs/teaching/rtcp-five-axis'
data = json.loads((OUT / 'numeric-evidence.json').read_text())
slides = []
def fig(name, caption='', cls=''):
return f'<figure class="{cls}"><img src="assets/{name}.png" alt="{html.escape(caption)}"><figcaption>{caption}</figcaption></figure>'
def page(chapter, title, body, takeaway='', sources='', cls=''):
slides.append({'chapter': chapter, 'title': title, 'body': body, 'takeaway': takeaway, 'sources': sources, 'cls': cls})
def table(headers, rows, cls=''):
return '<table class="'+cls+'"><thead><tr>'+''.join('<th>'+h+'</th>' for h in headers)+'</tr></thead><tbody>'+''.join('<tr>'+''.join('<td>'+str(x)+'</td>' for x in row)+'</tr>' for row in rows)+'</tbody></table>'
def fmt(x):
return f'{0 if abs(x)<0.0005 else x:.3f}'
def xyz(values): return '('+', '.join(fmt(v) for v in values[:3])+')'
page('教学课件 · 2026.09', '五轴机床与 RTCP',
'<div class="cover-grid"><div><p class="subtitle">从“各轴独立”到“刀尖跟随”</p><p>看懂两个坐标系、一棵结构树、<br>一条运动学计算链。</p><div class="rule"></div><p class="small">适用对象:数控入门、五轴调试与仿真教学<br>主线机型:XYZAC / XYZBC 双转台桁架机床<br>建议课时:60–90 分钟,可拆成两讲</p><p class="badge">原理图解 + 数值演算 + 浏览器实验 + 练习答案</p></div>'+fig('machine-bc','原创 BC 桁架模型:XYZ 移动刀具,B–C 转动工件','cover-model')+'</div>',
'核心问题:程序中的 XYZ,究竟是在告诉谁“去哪里”?', '图:本项目原创 GLB 模型;数值:LinuxCNC WASM 实算。', 'cover')
page('01 · 先统一理解', '你的两点理解,怎样说得更准确?',
'<div class="cols"><div><h3>① 非 RTCP:直接轴映射</h3><p>在本课的双转台结构中,XYZ 带动刀具,A/C 或 B/C 带动工件。</p><p><b>只改变旋转轴指令,不自动触发 XYZ 的刀尖保持补偿。</b>但多个轴仍可在同一段 G 代码中协调插补。</p><p class="small">“独立”描述几何映射,不是运动时间完全无关。</p></div><div><h3>② RTCP:保持刀尖相对工件的轨迹</h3><p>控制器根据编程点、转台角度、旋转中心和标定参数,计算实际关节位置。</p><p><b>工件坐标方向随转台变换,XYZ 关节按需联动。</b>计算还包括平移与偏置,不能只旋转方向箭头。</p><p class="small">“跟随”是相对工件满足目标关系,不是世界坐标中的刀尖永远不动。</p></div></div>',
'课程约定:先用零工件偏置、零刀长差的简化模型讲清原理,再加入实际参数。', '[S1][S2];结合本项目 AC/BC 模型。')
page('01 · 结构与轴名', '五轴 = 三个直线轴 + 两个旋转轴',
table(['名称','通常的轴线含义','本课中的执行机构'],[
['X / Y / Z','沿三个正交方向平移','刀具侧的直线滑台、滑枕'],
['A','绕 X 方向的轴线转动','AC 机床的外层倾转轴'],
['B','绕 Y 方向的轴线转动','BC 机床的外层倾转轴'],
['C','绕 Z 方向的轴线转动','装在倾转台上的内层旋转轴'],
])+'<div class="pair"><p><b>AC 型:</b>X、Y、Z、A、C</p><p><b>BC 型:</b>X、Y、Z、B、C</p></div><p class="small">XYZABC 六个独立轴同时配置,通常应称六轴。A/B/C 是旋转轴的通用名称,本课不是三只转台轴同时工作。C 的轴线会被父级 A/B 倾转,倾转后不再固定平行于机床 Z。</p>',
'从正方向看懂箭头前,先确认观察视角、父级坐标系和机床正负号约定。', '[S3] 轴命名;图中数值采用本项目原生变换约定。')
page('01 · 机械拓扑', '两条运动链,共用一个底座',
fig('hierarchy','本课桁架结构:X 带动整个横梁,Y 带动滑座,Z 带动滑枕与刀具。','wide-diagram')+
'<div class="cols tight"><p><b class="blue">刀具分支</b><br>底座 → X → Y → Z<br>运动从父节点传给所有子节点。</p><p><b class="teal">工件分支</b><br>底座 → A/B → C → 工件<br>A/B 转动时,C 的轴线也随之倾斜。</p></div>',
'这是一种五轴结构,不是所有五轴机床的通用拓扑;摆头、头台混合型需要不同模型。', '本项目 trt-xyz-gantry/1;旧标准型在 Z 下另有固定主轴箱节点。')
page('01 · 真实模型', 'AC 与 BC:外层倾转轴不同',
'<div class="cols model-pair">'+fig('machine-ac','AC 桁架:A 绕 X,内层 C 随 A 倾转')+fig('machine-bc','BC 桁架:B 绕 Y,内层 C 随 B 倾转')+'</div><p class="small">两者都能实现刀尖相对工件的五轴轨迹,但同一角度数值对应的空间姿态不同;不能只把程序里的 A 字母换成 B,就认为加工姿态完全相同。</p>',
'先识别“哪根轴装在哪根轴上”,再选择运动学模型。', '图:原创可下载 GLB;不是制造商的标定尺寸图。')
page('02 · 三种位置', '不要把“程序点、关节位置、实际反馈”混为一谈',
table(['量','回答的问题','典型表示'],[
['编程位置 p','刀尖相对所用编程参考系要到哪里?','(X, Y, Z),加 A/C 或 B/C'],
['关节指令 q','各执行轴需要到哪个位置?','(J0, J1, J2, J3, J4)'],
['实际反馈 q_actual','编码器报告电机/机械轴在哪里?','真实设备中可能存在跟随误差'],
['三维刀尖位置 r','把关节和模型组合后,刀尖在场景哪里?','显示坐标,不是独立运动控制源'],
])+'<p class="small">理想仿真中“关节指令 = 关节反馈”常成立,但真实机床两者仍须区分。RTCP 的运动学把 p 转成 q;模型再把 q 投影成画面。</p>',
'“实际位置”一词要说明是在说编程坐标、关节指令,还是编码器反馈。')
page('02 · G 代码含义', '非 RTCP 时,G 代码也不总等于机械绝对位置',
'<div class="flow"><span>程序字值</span><i>→</i><span>单位 / G90–G91</span><i>→</i><span>工件零点 / 补偿</span><i>→</i><span>运动学</span><i>→</i><span>关节指令</span></div>'+table(['因素','为什么不能直接等同'],[
['G90 与 G91','G90 通常给绝对目标;G91 给相对增量,X10 不一定是到 X=10。'],
['G54 等工件坐标系','工件零点可能偏离机床零点;程序 X10 可能对应机床 X30。'],
['G92、刀长等补偿','控制器还需处理偏置和刀具参考点。'],
['Identity 直接映射','在完成以上处理后,所选坐标与关节才按配置直接对应。'],
])+'<p class="small">简化例:G54 原点位于机床 (20,30,0),无其他偏置/补偿,程序点 (10,0,5) 对应机床点 (30,30,5)。</p>',
'只有把偏置、单位、增量方式、刀具基准都约定好,才能说“程序值直接对应轴位置”。', '[S2][S3];本页示例为无旋转的 Identity 情形。')
page('03 · 非 RTCP', '只转 C,工件点会离开刀尖',
fig('identity-no-follow','俯视图:蓝色为固定机床坐标;绿色为工件;红色为刀尖。Z 保持 20 mm。','large-diagram')+
'<p>起点刀尖对准转台上半径 100 mm 的标记点。只命令 C 从 0° 转到 90°,<b>XYZ 关节不补偿</b>,标记点转到另一处,刀尖仍在原来的机床位置。</p>',
'轴可以独立驱动,不代表“刀具与工件的相对位置”不受其他轴影响。')
page('03 · RTCP', '同样只改变角度,刀尖跟住工件点',
fig('tcp-follow','同一个工件局部点 p=(100,0,20),C=0° 与 C=90°;旋转中心和偏置取零。','large-diagram')+
'<p>保持编程点 p 不变,只改变 C。控制器计算所需 XYZ 关节,让刀尖到达标记点的新机床位置。<b>点在工件坐标中不变,在机床坐标中沿圆弧移动。</b></p>',
'判断“跟随”的参考系是工件,而不是屏幕或机床底座。')
page('03 · 数值算例 1', 'C 转 90°:同一编程点,不同关节位置',
'<p class="intro">零偏置、零刀长差,p = (100, 0, 20) mm;A/B = 0°。</p>'+table(['C 角度','RTCP 编程 XYZ','RTCP 所需关节 XYZ','非 RTCP:只转 C 的关节 XYZ'],[
[f'{s["c"]}°','(100, 0, 20)',xyz(s['joints']),'(100, 0, 20)'] for s in data['cases'][0]['samples'][:3]
])+'<div class="equation">q<sub>x</sub> = 100 cos C  q<sub>y</sub> = 100 sin C  q<sub>z</sub> = 20</div><p class="small">C=90° 时,XYZ 关节目标是 (0,100,20)。这不是控制器偷偷修改了程序点,而是同一点在另一参考系中的正确位置。</p>',
'RTCP:程序坐标可以不变,执行轴位置必须按姿态变化。', 'numeric-evidence.json;原生 WASM IK/FK 双向核对,显示保留3位小数。')
page('04 · 坐标方向', 'C = 45° 时,“沿工件 X 走”意味着什么?',
'<div class="cols image-right"><div><p>蓝色 X<sub>M</sub>、Y<sub>M</sub> 是机床固定方向;绿色 X<sub>W</sub>、Y<sub>W</sub> 随工件转动。</p><p>姿态保持 C=45°,编程增量:</p><div class="equation">Δp<sub>W</sub> = (10, 0, 0)</div><p>对应的机床直线轴增量:</p><div class="equation">Δq = (7.071, 7.071, 0)</div><p class="small">不是机床 X 导轨真的旋转了,而是 X、Y 导轨合成出所需空间运动方向。</p></div>'+fig('rotated-direction','图中工件原点为便于观看作了平移;增量不受原点平移影响。')+'</div>',
'你的“XYZ 方向随旋转改变”可理解为工件基向量在机床坐标中的方向改变。')
bc = data['cases'][1]['samples'][4]
page('04 · 数值算例 2', 'BC 倾转后,沿工件 X 会同时改变机床 X、Y、Z',
'<div class="cols image-right"><div><p>采用本课的正向变换约定:</p><div class="equation small-eq">R = R<sub>Y</sub>(B) · R<sub>Z</sub>(C)</div><p>当 B=30°、C=45° 时:</p><div class="equation small-eq">X<sub>W</sub> 在 M 中的方向<br>(0.6124, 0.7071, −0.3536)</div><p>所以沿 X<sub>W</sub> 前进 10 mm:</p><p class="number">Δq ≈ (6.124, 7.071, −3.536) mm</p><p class="small">倾斜后的坐标方向并不与任何一根直线导轨重合,需要三根直线轴合成。</p></div>'+fig('bc-frame','B=30°、C=45°;红点为工件点 p=(100,0,20) 的机床位置。')+'</div>',
'RTCP 中“沿工件 X”不是“只让 X 电机动”。')
ac = data['cases'][0]['samples'][4]
page('04 · AC 与 BC 对照', '同样的 30° / 45°,不是同一个姿态',
'<p class="intro">仍取 p=(100,0,20) mm,所有旋转中心、偏置和刀长差为零。</p>'+table(['结构','父级倾转 × 子级旋转','倾转30°、C45°时的关节XYZ'],[
['AC','R<sub>X</sub>(A) · R<sub>Z</sub>(C)',xyz(ac['joints'])],
['BC','R<sub>Y</sub>(B) · R<sub>Z</sub>(C)',xyz(bc['joints'])],
])+'<div class="cols"><div><h3>AC:绕 X 倾转</h3><p>C 先改变平面位置,A 再把局部 Y/Z 方向倾斜。</p></div><div><h3>BC:绕 Y 倾转</h3><p>C 先改变平面位置,B 再把局部 X/Z 方向倾斜。</p></div></div><p class="small">矩阵从右向左作用于列向量;这是坐标转换次序,不是要求 G 代码先运动 C 再运动 A/B。插补时两轴可以同时运动。</p>',
'运动学类型选错,即使每个轴都会动,刀尖也不会沿正确的相对轨迹走。', 'numeric-evidence.json;零偏置原生 WASM 结果。')
page('05 · 为什么还需要几何参数', '同样转 90°,绕不同的中心会到不同的位置',
fig('pivot-offset','二维俯视例:起点相同,旋转角度相同,仅旋转中心不同。','large-diagram')+
'<div class="equation">p<sub>M</sub> = O + R · (p − O)</div><p class="small">计算顺序:先减去旋转中心 → 旋转 → 再加回中心。不能只计算 R·p,否则相当于错误地绕坐标零点转。</p>',
'RTCP 不只是“方向变换”,还包括“绕哪里转”的平移关系。')
page('05 · 轴间偏置与变换顺序', 'C 轴装在倾转轴上:父子关系不能交换',
'<div class="cols"><div><h3>先看几何,再写方程</h3><p><b>P:</b>C 轴零位参考点。<br><b>D:</b>倾转轴与 C 轴之间的偏置。<br><b>T=P+D:</b>倾转轴参考位置。</p><p>C 旋转后的点还要随 A/B 倾转;偏置也必须放在正确的父子层级中。</p></div><div><h3>URDF / GLB 里的对应关系</h3>'+table(['节点','本项目零位 origin'],[
['A/B,父节点为底座','P + D'],['C,父节点为 A/B','−D'],['work,父节点为 C','−P'],
],'compact')+'</div></div><div class="equation small-eq">q<sub>XYZ</sub> = P + D + R<sub>tilt</sub> [ R<sub>C</sub> (p − P) − D ] (刀长差为零)</div><p class="small">这是本项目 AC/BC TRT 约定。更一般的机床使用完整的刚体变换链;不能把这三个参数关系硬套到摆头机床。长度在运行配置中为 mm,URDF 为 m。</p>',
'R<sub>tilt</sub>R<sub>C</sub> 通常不等于 R<sub>C</sub>R<sub>tilt</sub>;机械安装顺序决定计算顺序。', '本地 trtfuncs.c;本项目 URDF profile / D11 / D12。')
page('05 · 刀长与受控点', '控制的是刀尖,不只是主轴箱',
fig('tool-length','侧视示意:同一主轴参考位置,刀长不同会产生不同刀尖位置。','large-diagram')+
'<p>刀长改变时,为保持同一个刀尖目标,需要重新计算主轴参考点的位置。必须明确刀具长度的测量基准、正负号、刀补状态,以及模型上“tool”节点代表哪里。</p><p class="small">教学主算例采用刀长差为零。本项目原生 TRT 使用相对于 master tool 的 tool-offset 约定,不能把它直接当作任意模型网格的总长度。</p>',
'同一组关节位置 + 不同刀具标定,不一定得到同一个刀尖位置。', '[S1][S3];实际使用以机床和控制器的刀具标定约定为准。')
page('06 · 姿态的物理意义', '双转台没有转动主轴,却改变了相对刀轴方向',
fig('orientation','侧视:主轴在机床中保持竖直,工件随转台倾斜,二者的相对夹角改变。','large-diagram')+
'<p>在本课双转台机床中,刀轴在机床坐标里的方向固定;工件转动后,<b>刀轴在工件坐标里的方向改变</b>。RTCP 根据指定角度协调刀尖位置,不会自动推断曲面法向。</p>',
'RTCP 不是“自动让刀轴垂直于任意曲面”;姿态仍由程序、CAM 或操作者给定。')
page('06 · 三种常见联动情形', '分清“保持点”“沿方向走”“同时改变位置和姿态”',
table(['输入变化','RTCP 应做什么','观察重点'],[
['XYZ 不变;A/B、C 改变','让刀尖保持同一工件编程点;XYZ关节可能联动。','刀尖与工件标记点不分离。'],
['角度不变;XYZ 改变','沿当前工件参考系定义的方向移动。','机床多轴合成编程方向。'],
['XYZ 与角度同时改变','执行变化的编程轨迹,同时维持角度对应的几何关系。','全过程相对轨迹正确,不只看终点。'],
['XYZ 不变;点恰在 C 轴线上;只转 C','该点机床位置可以不变。','XYZ 不动不一定是故障。'],
])+'<p class="small">“五轴联动”不要求每个时刻五轴速度都非零。非 RTCP 也能多轴同步加工:CAM/后置可以预先按指定机床几何计算轴目标,但这不等于控制器开启了实时刀尖补偿。</p>',
'每次解释一个动作,先说清哪些程序量保持不变、哪些程序量在改变。')
page('06 · 全过程的重要性', '终点都正确,不代表中间轨迹正确',
'<div class="cols image-right"><div><p>同一个工件点绕 C 转动,理想半径为 100 mm。</p><p><b class="teal">RTCP:</b>刀尖跟随圆弧。<br><b class="red">不做 RTCP:</b>若只让 X/Y 线性插补到相同终点,刀尖可能走弦线。</p>'+table(['中间位置','X / Y'],[['圆弧,C=45°','70.711 / 70.711'],['弦线,50%进度','50.000 / 50.000']],'compact')+'<p class="small">图示采用共同线性进度比较几何路径,不是断言所有控制器的加减速过程相同。</p></div>'+fig('arc-chord','中途差距 29.289 mm;起点和终点却完全相同。')+'</div>',
'验收 RTCP 要看连续采样、刀尖相对轨迹和关节约束,不能只比首末坐标。')
switch = data['switch_example']
page('07 · 模式切换', 'M429 ↔ M428:坐标可以变,机床不能无故跳动',
'<p class="intro">本项目 BC 示例:保持同一组物理关节,切换坐标解释。</p>'+table(['量','M429 / Identity','M428 / TCP'],[
['物理关节 XYZ',xyz(switch['physical']),xyz(switch['physical'])],
['物理关节 B / C','4.8675° / 179.88°','4.8675° / 179.88°'],
['显示/解释的 XYZ',xyz([switch['identity_pose'][a] for a in 'xyz']),xyz([switch['tcp_pose'][a] for a in 'xyz'])],
['三维刀尖','同一空间位置','同一空间位置'],
])+'<div class="flow"><span>取当前关节 q</span><i>→</i><span>切换运动学</span><i>→</i><span>新模式正解 p</span><i>→</i><span>同步 TP / 解释器</span></div><p class="small">本例使用真实 BC 模型参数 P=(−18,14,12)、D=(22,0,−40) mm,刀长差0。切换之后再给运动指令,才产生运动。</p>',
'若切换时强行保持旧 XYZ 数字并重新逆解,就可能错误地改变物理关节。', '[S2] 同步原则;本地 control.c::handle_kinematicsSwitch;WASM实算。')
page('07 · 控制器功能边界', 'RTCP、旋转工作平面、刀补,不是同一个开关',
table(['功能','主要解决的问题','不要误认为'],[
['RTCP / TCP 运动学','姿态变化时,按机床结构实现目标刀尖相对轨迹。','所有控制器都会以完全相同方式解释XYZ。'],
['工件坐标系 / 倾斜工作平面','选择编程参考原点或定义编程方向。','只旋转工作平面就自动具备完整五轴RTCP。'],
['刀长补偿','把刀具参考点换算到实际受控刀尖。','更换刀具后只更新网格外观就够了。'],
['机床标定','确定中心、偏置、轴向、正负号和长度。','只要支持RTCP,错误几何参数也会自动纠正。'],
])+'<p class="small">不同控制器可能分别使用 TCP、TCPM、RTCP 等名称,并把倾斜平面/刀补组合进不同指令。本课明确讲本项目的 AC/BC 双转台语义,不推定所有品牌一致。</p>',
'换控制器时先核对“参考系、受控点、角度定义、指令组合”,不要只对照功能名称。')
page('08 · 程序对照', '同样写 C90,开关状态决定 XYZ 是否补偿',
'<div class="cols"><div><h3>M429:直接映射</h3><pre>G21 G90 G94\nM429\nG1 X100 Y0 Z20 A0 C0 F300\nG1 C90 F60</pre><p>后一段只改变 C。<br>XYZ 关节保持 (100,0,20),<br>工件标记点会离开刀尖。</p></div><div><h3>M428:RTCP</h3><pre>G21 G90 G94\nM428\nG1 X100 Y0 Z20 A0 C0 F300\nG1 C90 F60</pre><p>后一段保持编程 XYZ。<br>XYZ 关节变到 (0,100,20),<br>刀尖仍对准同一个工件点。</p></div></div><p class="small">仅为零偏置/零刀长差 AC 教学片段,起始角度和当前模态需已知;BC 用 B0。现有下载模型参数非零,不能期待相同数字。M428/M429 为本项目重映射,不是通用标准M码;F值只作插补示意,实际需按机床的旋转/混合进给规则设定。</p>',
'程序字值相同,不代表开关前后表达的是同一个机械目标。', '本项目 xyzac/xyzbc-trt.ini,428remap.ngc / 429remap.ngc。')
page('08 · 浏览器课堂实验', '用 AC/BC 桁架模型验证,而不是只看公式',
'<ol class="steps"><li><b>加载:</b>打开“导入机床模型”,选 AC 或 BC 桁架示例;记录旋转中心与偏置。</li><li><b>准备:</b>确认空闲,开机并回零;选一个离 C 轴线有一定距离的刀尖位置。</li><li><b>Identity:</b>选择 M429,只 Jog A/B 或 C;观察 XYZ 关节是否保持。</li><li><b>切换:</b>停止后选择 M428;观察关节/刀尖不跳,XYZ 坐标可能变化。</li><li><b>TCP:</b>再次 Jog 旋转轴;观察 XYZ 关节按需补偿、刀尖相对工件点保持。</li><li><b>对照:</b>先停止,再切回 M429;重复方向相同的小幅 Jog,比较两种行为。</li></ol><p class="small">实验先用小幅角度并远离限位。记录的是“每次试验起点相对工件的同一点”;切换或重新定位后,不要把两个不同起点混作同一个目标。</p>',
'同时观察三项:程序XYZ、J0…J4、刀尖与工件的相对关系。', '页面 http://127.0.0.1:5174/;浏览器仿真教学,不是机床现场操作规程。')
page('08 · 参数化实验', '改几何参数后,为什么仍应保留同一个模型?',
'<div class="cols"><div><h3>正确的数据流</h3><div class="vertical-flow"><span>参数表:中心 / 偏置</span><b>↓</b><span>有效 URDF:节点零位关系</span><b>↓</b><span>INI → Worker → WASM</span><b>↓</b><span>关节 → 原导入模型</span></div></div><div><h3>课堂观察</h3><ul><li>同一 GLB 的网格、材质和文件身份保持。</li><li>新的轴心与偏置应进入运行结构和运动学。</li><li>相同程序点和角度,可能得到不同的 XYZ 关节值。</li><li>参数改变后需重新回零并重新解析程序。</li></ul><p class="small">源GLB不自动改写;可保存INI记录参数。改变轴心不等于自动重建支架、铸件等CAD外形,也不提供碰撞检测。</p></div></div>',
'模型保留和参数生效必须同时成立;只改外观或只改计算都会造成误导。', '本项目 D11 / E09:AC、BC各18个姿态/刀长样本验证。')
page('09 · 约束与故障', '“工件点可达”不等于“每个关节都没到限位”',
'<div class="cols"><div><h3>两层约束</h3>'+table(['约束','检查什么'],[['坐标限位','编程轴/坐标允许的范围'],['关节限位','实际执行机构的物理行程'],['动力学约束','速度、加速度等能力']],'compact')+'<p>RTCP 旋转时,即使编程 XYZ 不变,补偿出来的 X/Y/Z 关节仍可能触及边界。</p></div><div><h3>本项目排障实例</h3><p>BC 配置允许较大的 B 坐标范围,但对应 JOINT_3 上限为 <b>50°</b>。</p><p>旧问题:越界 → 运动计算失败 → 事件时钟不同步 → Connection ERROR。</p><p>修复后:按关节约束停止,保留正确时钟,可松开并反向 Jog。</p></div></div><p class="small">本仿真对 RTCP 越界采用最后有效伺服样本停止,不等同于工业控制器的全路径限位预测与减速保护。范围和行为必须分别验证。</p>',
'正常限位阻止是一条运动诊断;序号缺口或连接错误是软件故障,不是RTCP原理。', '本项目 R07 / E11;不据此宣称硬件安全或碰撞防护。')
page('09 · 常见误解', '六个问题,快速判断是否真正理解',
table(['说法','准确解释'],[
['“非RTCP时各轴互相没有影响。”','没有自动补偿;但工件转动当然会改变它与刀具的相对位置。'],
['“RTCP把X/Y/Z导轨转了。”','导轨不转,是工件基方向改变,由固定导轨合成空间运动。'],
['“RTCP时XYZ永远不变。”','只在保持同一编程点时程序XYZ不变;关节XYZ通常变化。'],
['“只要开RTCP,刀尖在空间就不动。”','刀尖可在机床空间移动,以保持它相对工件的目标关系。'],
['“C转了但XYZ没动,一定坏了。”','目标点恰在C轴线上时可能正确,应先检查几何。'],
['“切换模式时XYZ数字跳,必须修好。”','要检查关节和物理刀尖是否跳;新参考系里的数值改变可能正确。'],
],'compact'),
'先说参考系,再说位置;先看结构,再说联动。')
page('10 · 课堂练习', '请先画箭头,再写结论',
'<ol class="exercises"><li><b>点追踪:</b>零偏置,C=0°,工件点 p=(80,0,10)。只转 C 到90°,分别写出 Identity 和 RTCP 的 XYZ 关节目标。</li><li><b>方向合成:</b>C=30°且倾转角为0°,沿工件 +X 方向走20mm,机床 X/Y 各应增加多少?</li><li><b>模式切换:</b>M429切到M428时,XYZ显示值变化、J0…J4不变、刀尖不动,是否正常?为什么?</li><li><b>偏置识别:</b>BC模型 C 的局部 origin=(-0.022,0,0.040)m,倾转轴 origin=(0.004,0.014,-0.028)m。求本项目 P、D,单位mm。</li><li><b>结构判断:</b>C节点错误地挂在Z滑枕下。只Jog X时会发生什么?为什么不能靠RTCP开关解决?</li></ol>',
'建议分组讨论 8–10 分钟;答案在下一页。')
page('10 · 参考答案', '用“相对谁”检查每一步',
'<ol class="answers"><li><b>Identity:</b>(80,0,10);<b>RTCP:</b>(0,80,10)。前者刀尖留在机床原位置,后者继续对准同一工件点。</li><li><b>ΔX=20cos30°=17.321mm;ΔY=20sin30°=10.000mm。</b>方向向量长度仍为20mm,机床Z不变。</li><li><b>正常。</b>运动学切换重新解释当前物理状态。坐标可以变,关节和刀尖应保持。</li><li><b>P=T+U=(-18,14,12)mm;D=−U=(22,0,−40)mm。</b>先统一单位,再按父子关系相加,不能直接拿URDF的米当毫米。</li><li><b>C及工件会错误地随XYZ移动。</b>这是结构树错误,不是开关模式能修复的;需把A/B→C分支正确挂回底座。</li></ol>',
'检查顺序:轴数 → 层级 → 单位/正负号 → 几何参数 → 运动学 → 程序与状态。')
page('附录 A · 数学推导', '从一根 C 轴,推到两根旋转轴',
'<div class="cols"><div><h3>绕 C 的平面变换</h3><pre class="math">x′ = x cos C − y sin C\ny′ = x sin C + y cos C\nz′ = z</pre><h3>AC:再绕 X 倾转 A</h3><pre class="math">qₓ = x′\nqᵧ = y′ cos A − z′ sin A\nq𝓏 = y′ sin A + z′ cos A</pre></div><div><h3>BC:再绕 Y 倾转 B</h3><pre class="math">qₓ = x′ cos B + z′ sin B\nqᵧ = y′\nq𝓏 = −x′ sin B + z′ cos B</pre><p>这里所有旋转中心、轴间偏置及刀长差为零,列向量采用右手正向变换。</p><p class="small">式中的 q 是关节目标;若导入的是其他结构、符号约定或参考点,必须重新建立变换链。</p></div></div>',
'先学会解释“这一步在谁的坐标系中发生”,再记忆公式。', '与本地 trtfuncs.c 零偏置逆解一致;三角函数实际以弧度计算。')
page('附录 B · 本项目完整参数式', '加入旋转中心、偏置与 master-tool 刀长差',
'<div class="equation">D<sub>L</sub> = D + L e<sub>z</sub></div><div class="equation">q<sub>XYZ</sub> = P + D<sub>L</sub> + R<sub>tilt</sub> [ R<sub>C</sub> (p − P) − D<sub>L</sub> ]</div>'+table(['符号','AC','BC'],[
['R<sub>tilt</sub>','R<sub>X</sub>(A)','R<sub>Y</sub>(B)'],['D','(0, D<sub>y</sub>, D<sub>z</sub>)','(D<sub>x</sub>, 0, D<sub>z</sub>)'],['旋转关节','q<sub>A</sub>=A,q<sub>C</sub>=C','q<sub>B</sub>=B,q<sub>C</sub>=C'],
],'compact')+'<p class="small">P为原生rot-point;L为本项目/原生TRT的tool-offset标定量;e<sub>z</sub>=(0,0,1)。本式返回关节XYZ,不是未经处理的网格刀尖世界坐标。G54等解释器偏置在输入p形成前已按控制器规则处理。</p><p class="small">这一刀长表达是本项目约定,不能替代任意控制器的通用刀补说明。主课例子L=0;实际机床应以标定模型及其完整前后向验证为准。</p>',
'工程实现不在画面中“补一个偏移”;应让同一组结构数据贯穿解释器、运动学和模型。', '原生源码:linuxcnc/src/emc/kinematics/trtfuncs.c,xyzac/xyzbcKinematicsInverse。')
page('附录 C · 教学安排与依据', '怎样讲、怎样验证、怎样继续学习',
'<div class="cols references"><div><h3>建议授课节奏</h3><p>10分钟:结构、轴名与三种位置。<br>20分钟:C轴点跟踪、方向合成、AC/BC倾转。<br>15分钟:旋转中心、偏置、刀长与模式切换。<br>15分钟:浏览器实验与错误诊断。<br>10分钟:练习、答案和模型参数核对。</p><h3>本课验算</h3><p>12个姿态由真实WASM逆解计算,并通过正解回代;另核对C45°方向增量、BC切换示例及弧弦差。详见同目录 <code>numeric-evidence.json</code>。</p><p>图形为原创教学示意及本项目GLB截图,不用于制造尺寸标定。</p></div><div><h3>官方资料与本地实现</h3><p id="S1"><b>[S1]</b> <a href="https://linuxcnc.org/docs/html/motion/5-axis-kinematics.html">LinuxCNC · 5-Axis Kinematics</a><br>AC/BC结构、运动学及偏置。</p><p id="S2"><b>[S2]</b> <a href="https://linuxcnc.org/docs/html/motion/switchkins.html">LinuxCNC · Switchable Kinematics</a><br>模式切换、identityfirst、同步要求。</p><p id="S3"><b>[S3]</b> <a href="https://linuxcnc.org/docs/html/gcode/machining-center.html">LinuxCNC · CNC Machine Overview</a><br>轴名、坐标、受控点与协调运动。</p><p><b>本地:</b>trtfuncs.c;control.c;428remap.ngc / 429remap.ngc;docs/glb-machine/。M428=TCP、M429=Identity仅适用于本项目配置。</p><p class="small">资料核对日期:2026-09-18。不同版本/品牌的指令与坐标约定应另行核查。</p></div></div>',
'一句话记忆:程序描述相对工件的目标,运动学把目标换算成各关节应该怎样动。')
css = r'''
@page { size: 338.6667mm 190.5mm; margin: 0; }
* { box-sizing: border-box; }
html,body { margin:0; padding:0; background:#dce3e7; color:#23303a; font-family:"Noto Sans CJK SC","Noto Sans CJK JP",sans-serif; }
.slide { width:1280px; height:720px; padding:34px 54px 28px; background:#fff; position:relative; overflow:hidden; break-after:page; page-break-after:always; }
.slide:last-child { break-after:auto; page-break-after:auto; }
.chapter { color:#00877b; font-size:15px; margin:0 0 10px; font-weight:600; }
h1 { font-size:36px; font-weight:700; line-height:1.3; margin:0 0 24px; letter-spacing:0; }
h3 { font-size:23px; line-height:1.4; margin:0 0 14px; color:#00877b; }
p { font-size:22px; line-height:1.55; margin:0 0 17px; }
.body { height:498px; overflow:hidden; }
.cols { display:grid; grid-template-columns:1fr 1fr; gap:36px; }
.image-right { grid-template-columns: .95fr 1.1fr; gap:24px; }
.tight p { margin-bottom:0; font-size:20px; }
.small { font-size:17px; line-height:1.6; color:#53636d; }
.intro { font-size:22px; padding-bottom:8px; }
.takeaway { position:absolute; left:54px; right:54px; bottom:62px; background:#eaf5f2; border-left:5px solid #00877b; padding:10px 15px; font-size:19px; line-height:1.5; }
.footer { position:absolute; bottom:20px; left:54px; right:54px; display:flex; align-items:flex-end; justify-content:space-between; gap:20px; color:#6b7880; font-size:12px; line-height:1.5; border-top:1px solid #d8e1e6; padding-top:8px; }
.footer .source { max-width:1030px; }
.page-number { font-size:16px; color:#00877b; white-space:nowrap; }
figure { margin:0; min-width:0; }
figure img { width:100%; height:380px; object-fit:contain; display:block; }
figcaption { font-size:14px; line-height:1.5; margin-top:5px; color:#617079; text-align:center; }
.large-diagram img { height:306px; }
.large-diagram { margin-bottom:15px; }
.wide-diagram img { height:280px; }
.model-pair img { height:373px; }
.model-pair { margin-bottom:18px; }
table { border-collapse:collapse; width:100%; margin:8px 0 24px; font-size:19px; line-height:1.55; }
th { background:#eaf0f3; font-weight:600; text-align:left; border-bottom:2px solid #a5b8c1; padding:12px 14px; }
td { padding:13px 14px; border-bottom:1px solid #d8e1e6; vertical-align:top; }
td:first-child { font-weight:600; color:#2563b8; }
.compact { font-size:18px; }
.compact td,.compact th { padding:9px 12px; }
.equation { text-align:center; background:#f1f5f7; padding:14px 18px; font-size:26px; line-height:1.5; margin:14px 0 20px; }
.small-eq { font-size:23px; }
.flow { display:flex; gap:12px; align-items:center; margin:8px 0 25px; font-size:18px; }
.flow span { flex:1; background:#edf4f5; padding:13px 8px; text-align:center; }
.flow i { color:#00877b; font-size:25px; font-style:normal; }
.pair { display:flex; justify-content:space-around; }
.pair p { color:#00877b; }
.number { font-size:23px; color:#d45549; }
.blue { color:#2563b8; } .teal { color:#00877b; } .red { color:#d45549; }
pre { font-family:"DejaVu Sans Mono","Noto Sans Mono CJK SC",monospace; white-space:pre-wrap; background:#edf2f5; border-left:4px solid #2563b8; padding:18px; font-size:21px; line-height:1.8; margin:12px 0 20px; }
.math { font-family:"Noto Sans CJK SC",sans-serif; font-size:23px; line-height:1.65; }
ul,ol { margin:8px 0 14px; padding-left:29px; font-size:22px; line-height:1.6; }
li { margin-bottom:12px; }
.steps { font-size:21px; line-height:1.6; }
.steps li { padding-left:6px; margin-bottom:15px; }
.exercises,.answers { font-size:22px; line-height:1.65; }
.exercises li,.answers li { margin-bottom:19px; padding-left:7px; }
.vertical-flow { display:flex; flex-direction:column; align-items:center; gap:4px; font-size:22px; }
.vertical-flow span { padding:12px 16px; background:#f0f5f7; width:100%; text-align:center; }
.vertical-flow b { color:#00877b; }
.references p { font-size:18px; line-height:1.6; margin-bottom:13px; }
.references h3 { margin-bottom:10px; }
a { color:#136fb0; text-decoration:none; }
sub { font-size:.7em; }
.cover h1 { font-size:48px; margin-top:8px; margin-bottom:18px; }
.cover-grid { display:grid; grid-template-columns:1.12fr .88fr; gap:55px; }
.cover .subtitle { font-size:30px; color:#00877b; line-height:1.4; margin-bottom:22px; }
.cover-model img { height:402px; background:#242a2a; }
.rule { width:85px; height:5px; background:#d45549; margin:25px 0; }
.badge { color:#2563b8; font-size:17px; margin-top:21px; }
.slide[data-page="12"] figure img { height:345px; }
.slide[data-page="12"] .cols p { margin-bottom:12px; }
.slide[data-page="12"] .equation { margin:10px 0 14px; padding:10px 14px; }
.slide[data-page="30"] .equation { margin:8px 0; padding:8px 14px; font-size:24px; }
.slide[data-page="30"] table { margin-bottom:14px; }
.slide[data-page="30"] p { margin-bottom:10px; }
@media screen { .slide { margin:24px auto; box-shadow:0 3px 14px #0002; } }
@media print { html,body { background:#fff; } .slide { margin:0; box-shadow:none; } * { -webkit-print-color-adjust:exact; print-color-adjust:exact; } }
'''
# Avoid exotic script glyphs in equations so CJK PDF fonts remain reliable.
parts = []
for index, slide in enumerate(slides, 1):
body = slide['body'].replace('q𝓏','q_z').replace('qₓ','q_x').replace('qᵧ','q_y')
parts.append(f'''<section class="slide {slide['cls']}" data-page="{index}">
<div class="chapter">{slide['chapter']}</div><h1>{slide['title']}</h1>
<div class="body">{body}</div>
<div class="takeaway">{slide['takeaway']}</div>
<footer class="footer"><span class="source">{slide['sources'] or '五轴机床与 RTCP · 原创教学示意 · 以本课双转台结构为限'}</span><span class="page-number">{index:02d} / {len(slides):02d}</span></footer>
</section>''')
document = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>五轴机床与 RTCP:从各轴独立到刀尖跟随</title><style>'+css+'</style></head><body>'+''.join(parts)+'</body></html>'
(OUT / 'course.html').write_text(document)
(OUT / 'outline.json').write_text(json.dumps([{'page':i+1,'chapter':s['chapter'],'title':s['title']} for i,s in enumerate(slides)], ensure_ascii=False,indent=2)+'\n')
print(f'Built {len(slides)} teaching pages:', OUT / 'course.html')

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const directory = new URL('../docs/teaching/rtcp-beginners/', import.meta.url);
await mkdir(new URL('verification/',directory),{recursive:true});
const browser = await chromium.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox']});
try {
const page = await browser.newPage({viewport:{width:794,height:1123}});
const errors=[];page.on('pageerror',(e)=>errors.push(e.message));
await page.goto(new URL('handbook.html',directory).href);await page.emulateMedia({media:'print'});
await page.evaluate(async()=>{await document.fonts.ready;await Promise.all([...document.images].map((im)=>im.decode()));});
const checks=await page.locator('.sheet').evaluateAll((pages)=>pages.map((sheet)=>{
const outer=sheet.getBoundingClientRect(),body=sheet.querySelector('.body'),key=sheet.querySelector('.key').getBoundingClientRect();
const violations=[...body.querySelectorAll('p,li,table,figure,pre,.equation,aside,h2')].filter((e)=>{
const r=e.getBoundingClientRect();return r.bottom>key.top-6||r.right>outer.right-32||r.left<outer.left+28;
}).map((e)=>({tag:e.tagName,text:e.textContent.slice(0,70),bottom:e.getBoundingClientRect().bottom-outer.top}));
const title=sheet.querySelector('h1');const range=document.createRange();range.selectNodeContents(title);
return {page:Number(sheet.dataset.page),title:title.textContent,violations,overflow:body.scrollHeight>body.clientHeight+1,title_right:range.getBoundingClientRect().right-outer.left,images:[...sheet.querySelectorAll('img')].map((im)=>({src:im.getAttribute('src'),loaded:im.complete&&im.naturalWidth>0})),characters:body.innerText.length};
}));
await writeFile(new URL('verification/layout.json',directory),JSON.stringify({report_id:'rtcp-beginners-layout-v1',pages:checks,errors},null,2)+'\n');
for(const check of checks) await page.locator(`.sheet[data-page="${check.page}"]`).screenshot({path:fileURLToPath(new URL(`verification/page-${String(check.page).padStart(2,'0')}.png`,directory))});
const bad=checks.filter((c)=>c.violations.length||c.overflow||c.title_right>760||c.images.some((im)=>!im.loaded));
assert.deepEqual(errors,[]);assert.deepEqual(bad,[],'Handbook page overflow');
await page.pdf({path:fileURLToPath(new URL('五轴RTCP与工件坐标-初学者完整讲义.pdf',directory)),preferCSSPageSize:true,printBackground:true,tagged:true,outline:true});
console.log(JSON.stringify({report_id:'rtcp-beginners-layout-v1',status:'verified',pages:checks.length}));
} finally {await browser.close();}

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import { pathToFileURL, fileURLToPath } from 'node:url';
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const directory = new URL('../docs/teaching/rtcp-five-axis/', import.meta.url);
await mkdir(new URL('verification/', directory), { recursive: true });
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
const errors = []; page.on('pageerror', (e) => errors.push(e.message));
await page.goto(new URL('course.html', directory).href);
await page.emulateMedia({ media: 'print' });
await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.images].map((im) => im.decode())); });
const checks = await page.locator('.slide').evaluateAll((slides) => slides.map((slide) => {
const rect = slide.getBoundingClientRect(), body = slide.querySelector('.body'), takeaway = slide.querySelector('.takeaway').getBoundingClientRect();
const clipped = [...body.querySelectorAll('p,li,table,figure,pre,.equation,.flow,h3')].filter((element) => {
const r = element.getBoundingClientRect();
return r.bottom > takeaway.top - 5 || r.right > rect.right - 35 || r.left < rect.left + 30;
}).map((element) => ({ tag: element.tagName, text: element.textContent.slice(0, 80), bottom: element.getBoundingClientRect().bottom - rect.top }));
const title = slide.querySelector('h1');
const range = document.createRange(); range.selectNodeContents(title);
const titleBounds = range.getBoundingClientRect();
return { page: Number(slide.dataset.page), title: title.textContent, clipped, content_overflow: body.scrollHeight > body.clientHeight + 1, title_right: titleBounds.right - rect.left, images: [...slide.querySelectorAll('img')].map((im) => ({ src: im.getAttribute('src'), complete: im.complete && im.naturalWidth > 0 })) };
}));
await writeFile(new URL('verification/layout.json', directory), JSON.stringify({ pages: checks, errors }, null, 2) + '\n');
for (const check of checks) {
await page.locator(`.slide[data-page="${check.page}"]`).screenshot({ path: new URL(`verification/page-${String(check.page).padStart(2, '0')}.png`, directory).pathname });
}
const bad = checks.filter((c) => c.clipped.length || c.content_overflow || c.title_right > 1230 || c.images.some((im) => !im.complete));
assert.deepEqual(errors, []);
assert.deepEqual(bad, [], 'PDF slide overflow or missing assets');
await page.pdf({ path: fileURLToPath(new URL('五轴机床与RTCP-教学课件.pdf', directory)), preferCSSPageSize: true, printBackground: true, tagged: true, outline: true });
console.log(JSON.stringify({ report_id: 'rtcp-teaching-layout-v1', pages: checks.length, status: 'verified', pdf: '五轴机床与RTCP-教学课件.pdf' }));
} finally { await browser.close(); }

View File

@@ -0,0 +1,28 @@
"""Embed only the authored Unicode codepoints, avoiding CJK radical aliases."""
import html
import shutil
from pathlib import Path
from fontTools import subset
from fontTools.ttLib import TTFont
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT/'docs/teaching/rtcp-beginners'
characters=set(html.unescape((OUT/'handbook.html').read_text()))
for style in ['Regular','Bold']:
font=TTFont(f'/usr/share/fonts/opentype/noto/NotoSansCJK-{style}.ttc',fontNumber=2)
options=subset.Options()
options.name_IDs=['*'];options.name_legacy=True;options.name_languages=['*']
tool=subset.Subsetter(options=options);tool.populate(unicodes={ord(c) for c in characters});tool.subset(font)
for record in font['name'].names:
replacement={1:'RtcpHandbookCJK',2:style,3:f'RtcpHandbookCJK-{style}',4:f'RtcpHandbookCJK {style}',6:f'RtcpHandbookCJK-{style}'}.get(record.nameID)
if replacement:
record.string=replacement.encode(record.getEncoding())
if 'CFF ' in font:
cff=font['CFF '].cff
cff.fontNames=[f'RtcpHandbookCJK-{style}']
cff.topDictIndex[0].FamilyName='RtcpHandbookCJK'
cff.topDictIndex[0].FullName=f'RtcpHandbookCJK {style}'
font.save(OUT/f'assets/handbook-{style.lower()}.otf')
font.close()
shutil.copyfile('/usr/share/doc/fonts-noto-cjk/copyright',OUT/'assets/font-license.txt')
print('Embedded SC fonts subset to',len(characters),'authored characters')

View File

@@ -13,7 +13,7 @@ const [contract, app, panel, preview] = await Promise.all([
const controlIds = [
"menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close",
"toolbar-estop", "toolbar-power", "toolbar-open", "toolbar-load-case", "toolbar-reload", "toolbar-run", "toolbar-step", "toolbar-pause-resume", "toolbar-stop", "toolbar-block-delete", "toolbar-optional-pause", "toolbar-zoom-in", "toolbar-zoom-out", "toolbar-view-z", "toolbar-view-z2", "toolbar-view-x", "toolbar-view-y", "toolbar-view-y2", "toolbar-view-p", "toolbar-rotate", "toolbar-clear-live-plot",
"workspace-case-select", "workspace-load-3d-chips", "manual-home-all", "manual-home-joint-select",
"workspace-case-select", "workspace-load-3d-chips", "workspace-rtcp-programs", "rtcp-program-scope", "rtcp-program-select", "rtcp-program-source", "rtcp-program-load", "rtcp-program-download", "machine-manager-settings", "machine-manager-import", "machine-manager-files", "machine-manager-translate", "machine-manager-rotate", "machine-manager-role", "machine-manager-parent", "machine-manager-show-origin", "machine-manager-apply", "machine-manager-export", "manual-home-all", "manual-home-joint-select",
"switchkins-0", "switchkins-1", "switchkins-2",
"tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro",
"manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c",
@@ -28,7 +28,8 @@ const controlIds = [
];
for (const id of controlIds) assert.ok(contract.includes(`\`${id}\``), `control missing from source contract: ${id}`);
const source = `${app}\n${panel}\n${preview}`;
const [library, manager] = await Promise.all([read("web/react/src/RtcpProgramLibrary.tsx"), read("web/react/src/MachineManagerPanel.tsx")]);
const source = `${app}\n${panel}\n${preview}\n${library}\n${manager}`;
for (const term of ["data-axis-control", "manual-axis-${axis}", "switchkins-${item.type}", "preview-${action}", "preview-layer-${layer}"]) assert.ok(source.includes(term), `control marker missing: ${term}`);
for (const forbidden of ["Prepare T1", ">Change</TextButton>", "Arm Probe", ">Disarm</TextButton>", "Reload editor", "program-actions\"><TextButton", '<Menu label="Machine">', '<Menu label="View">']) assert.equal(source.includes(forbidden), false, `non-source or duplicate control rendered: ${forbidden}`);

View File

@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const output = new URL('../coverage/glb-machine/gantry/', import.meta.url);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const errors = []; page.on('pageerror', (e) => errors.push(e.message));
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const settled = () => page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
const report = { report_id: 'gantry-machine-browser-v1', status: 'running', examples: [], layouts: [] };
try {
await page.goto('http://127.0.0.1:5174/'); await settled();
for (const id of ['ac', 'bc', 'ac-gantry', 'bc-gantry']) {
await control('machine-glb-settings').click(); await control(`machine-glb-${id}`).click(); await settled();
const s = await page.evaluate(async (id) => {
const api = await import('/src/imported-machine.ts'), parser = await import('/src/machine-urdf.ts');
const catalog = await (await fetch('/machines/manifest.json')).json(), entry = catalog.machines.find((e) => e.id === id);
const buffer = await (await fetch(`/machines/${entry.filename}`)).arrayBuffer();
const asset = await api.loadImportedMachine(buffer, entry.filename), structure = asset.structure;
if (asset.hash !== document.querySelector('.xyzac-machine-view').dataset.machineGlb) throw new Error('UI example differs from file');
const model = api.buildImportedMachine(asset);
const gantry = id.endsWith('gantry');
if (structure.profile !== (gantry ? 'trt-xyz-gantry/1' : 'trt-xyz-head/1')) throw new Error('wrong profile');
const find = (c) => structure.joints.find((j) => j.coordinate === c);
if (find('X').parent !== structure.base || find('Y').parent !== find('X').child || find('Z').parent !== find('Y').child) throw new Error('bad XYZ chain');
if (find(structure.coordinates[3]).parent !== structure.base || find('C').parent !== find(structure.coordinates[3]).child) throw new Error('bad rotary chain');
if (gantry && (structure.tool !== find('Z').child || structure.links.length !== 7)) throw new Error('tool must be Z link');
const rejections = [];
if (gantry) for (const [name, xml] of [
['wrong-tool', structure.urdf.replace('tool="z_slide"', 'tool="y_slide"')],
['wrong-profile', structure.urdf.replace('trt-xyz-gantry/1', 'trt-xyz-head/1')],
['rotary-on-xyz', structure.urdf.replace('<parent link="base"/><child link="tilt_table"/>', '<parent link="z_slide"/><child link="tilt_table"/>')],
]) { let rejected = false; try { parser.parseMachineUrdf(xml); } catch { rejected = true; } if (!rejected) throw new Error(`accepted ${name}`); rejections.push(name); }
const nodes = [];
if (gantry) model.machine.traverse((n) => { if (n.name.startsWith('moving_bridge_chord_') || n.name.startsWith('gantry_y_carriage_') || n.name.startsWith('gantry_z_ram_') || n.name.startsWith('gantry_post_')) nodes.push(n); });
const state = { offsets: { ...structure.offsets, tool_offset: 0 }, joints: [0, 0, 0, 0, 0] };
const capture = () => nodes.map((n) => ({ name: n.name, parent: n.parent.name, position: n.getWorldPosition(model.toolTip.position.clone()).toArray() }));
model.update(state); const initial = capture();
model.update({ ...state, joints: [20, 10, 5, 0, 0] }); const moved = capture();
for (let i = 0; i < nodes.length; i++) {
const name = nodes[i].name;
const expected = name.startsWith('gantry_post_') ? [0, 0, 0] : name.startsWith('moving_bridge_chord_') ? [.2, 0, 0] : name.startsWith('gantry_y_carriage_') ? [.2, 0, .1] : [.2, .05, .1];
moved[i].position.forEach((v, k) => { if (Math.abs(v - initial[i].position[k] - expected[k]) > 1e-8) throw new Error(`wrong branch motion ${name}`); });
}
const tableBefore = model.joints.rotate.getWorldPosition(model.toolTip.position.clone()).toArray();
model.update(state); const tableAfter = model.joints.rotate.getWorldPosition(model.toolTip.position.clone()).toArray();
if (tableBefore.some((v, k) => Math.abs(v - tableAfter[k]) > 1e-8)) throw new Error('table moved with XYZ');
model.dispose(); api.disposeMachineScene(asset.scene);
return { entry, profile: structure.profile, links: structure.links, tool: structure.tool, initial, moved, rejections };
}, id);
const name = id.endsWith('gantry') ? `下载 ${id.slice(0, 2).toUpperCase()}桁架型` : `下载 ${id.toUpperCase()}`;
const downloadEvent = page.waitForEvent('download'); await page.getByRole('link', { name, exact: true }).click();
const download = await downloadEvent; assert.equal(createHash('sha256').update(await readFile(await download.path())).digest('hex'), s.entry.sha256);
await page.keyboard.press('Escape'); await page.locator('.axis-preview-machine').screenshot({ path: new URL(`${id}.png`, output).pathname });
report.examples.push(s);
}
for (const [width, height] of [[1440, 1000], [390, 844]]) {
await page.setViewportSize({ width, height }); await control('machine-glb-settings').click();
const b = await page.locator('.model-dialog button, .model-dialog a').evaluateAll((es) => es.map((e) => { const r = e.getBoundingClientRect(); return { x: r.x, right: r.right, y: r.y, bottom: r.bottom }; }));
assert.ok(b.every((r) => r.x >= 0 && r.right <= width && r.y >= 0 && r.bottom <= height));
await page.screenshot({ path: new URL(`dialog-${width}.png`, output).pathname }); await page.keyboard.press('Escape'); report.layouts.push({ width, height, controls: b.length });
}
assert.deepEqual(errors, []); report.status = 'verified';
} catch (e) { report.status = 'failed'; report.error = String(e.stack ?? e); await page.screenshot({ path: new URL('failure.png', output).pathname }); throw e; }
finally { report.browser_errors = errors; await writeFile(new URL('catalog.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); }
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, examples: report.examples.map((e) => e.entry.id) }));

View File

@@ -0,0 +1,201 @@
import assert from 'node:assert/strict';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import createModule from '../build-wasm/linuxcnc-sim-core.js';
import { WasmXyzacKinematics } from '../web/worker/wasm-xyzac-kinematics.mjs';
import { SimSession, CONTEXT_ID } from '../web/worker/sim-session.mjs';
import { loadSimulationCase, toWorkerConfiguration } from '../web/workspace/sim-case-loader.mjs';
import { writeTrtGeometry } from '../web/workspace/trt-geometry.mjs';
import { MACHINE_DEFINITIONS } from '../web/workspace/machine-definitions.mjs';
const root = new URL('../', import.meta.url), output = process.env.GLB_EVIDENCE_DIR ? pathToFileURL(`${process.env.GLB_EVIDENCE_DIR.replace(/\/$/, '')}/`) : new URL('coverage/glb-machine/', root);
await mkdir(output, { recursive: true });
const wasmBinary = await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root));
const core = await createModule({ wasmBinary });
const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } };
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
const errors = []; page.on('pageerror', (e) => errors.push(e.message));
const report = { report_id: 'glb-machine-browser-v1', status: 'running', wasm_sha256: createHash('sha256').update(wasmBinary).digest('hex'), parser: [], machines: [], viewports: [] };
const control = (id) => page.locator(`[data-axis-control="${id}"]`), machine = page.locator('.xyzac-machine-view');
const settled = async () => { await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false'); };
const rendered = async () => { await page.waitForFunction(() => { const e = document.querySelector('.xyzac-machine-view'); return e?.dataset.xyzacTick === e?.dataset.xyzacRenderedTick; }); };
const sample = () => machine.evaluate((e) => ({ joints: JSON.parse(e.dataset.xyzacJoints), offsets: JSON.parse(e.dataset.xyzacOffsets), tip: JSON.parse(e.dataset.xyzacToolTip), tick: Number(e.dataset.xyzacTick), hash: e.dataset.machineGlb }));
const jog = async (axis, index, delta) => {
await control(`manual-axis-${axis}`).check(); const before = await sample();
const box = await control('manual-jog-positive').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down();
try { await page.waitForFunction(({ index, target }) => JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[index] >= target, { index, target: before.joints[index] + delta }); }
finally { await page.mouse.up(); }
await page.waitForFunction(() => document.querySelector('.command-status')?.textContent.includes('JOG_STOP acknowledged') || [...document.querySelectorAll('span')].some((s) => s.textContent === 'JOG_STOP acknowledged'));
await rendered(); return { axis, before, after: await sample() };
};
try {
await page.goto(process.env.XYZAC_PANEL_URL ?? 'http://127.0.0.1:5174/'); await settled();
// Exercise the browser's actual XML/GLB parser and GLTFLoader before touching the live machine.
for (const id of ['ac', 'bc']) {
const descriptor = await page.evaluate(async (id) => {
const api = await import('/src/imported-machine.ts');
const parser = await import('/src/machine-urdf.ts');
const bytes = await (await fetch(`/machines/demo-${id}-xyz-head.glb`)).arrayBuffer();
const { json, structure } = parser.inspectMachineGlb(bytes);
const results = [];
const reject = (name, action) => { let error = ''; try { action(); } catch (e) { error = e.message; } if (!error) throw new Error(`must reject ${name}`); results.push({ name, error }); };
const mutate = (change) => {
const next = structuredClone(json); change(next);
const view = new DataView(bytes), oldJsonLength = view.getUint32(12, true);
const bin = new Uint8Array(bytes, 20 + oldJsonLength);
const text = new TextEncoder().encode(JSON.stringify(next)); const length = Math.ceil(text.length / 4) * 4;
const data = new Uint8Array(20 + length + bin.length); const header = new DataView(data.buffer);
header.setUint32(0, 0x46546c67, true); header.setUint32(4, 2, true); header.setUint32(8, data.length, true);
header.setUint32(12, length, true); header.setUint32(16, 0x4e4f534a, true); data.fill(32, 20, 20 + length); data.set(text, 20); data.set(bin, 20 + length);
return () => parser.inspectMachineGlb(data.buffer);
};
reject('truncated', () => parser.inspectMachineGlb(bytes.slice(0, bytes.byteLength - 1)));
reject('missing-schema', mutate((j) => { delete j.scenes[0].extras; }));
reject('external-buffer', mutate((j) => { j.buffers[0].uri = 'https://invalid.example/data.bin'; }));
reject('external-texture', mutate((j) => { j.images = [{ uri: 'file:///etc/passwd' }]; }));
reject('animation', mutate((j) => { j.animations = [{}]; }));
reject('node-cycle', mutate((j) => { j.nodes[0].children = [0]; }));
reject('wrong-hierarchy', mutate((j) => { const i = j.nodes.findIndex((n) => n.name === 'c_table'); j.nodes.find((n) => n.name === 'tilt_table').children = j.nodes.find((n) => n.name === 'tilt_table').children.filter((n) => n !== i); j.nodes.find((n) => n.name === 'base').children.push(i); }));
reject('node-offset-mismatch', mutate((j) => { j.nodes.find((n) => n.name === 'c_table').translation[2] += .01; }));
reject('duplicate-link', mutate((j) => { j.nodes.find((n) => n.name?.startsWith('bed_')).name = 'base'; }));
const matrixCompatible = mutate((j) => {
const node = j.nodes.find((n) => n.name === 'c_table'), v = node.translation;
delete node.translation; delete node.rotation; delete node.scale;
node.matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, ...v, 1];
});
if (matrixCompatible().structure.coordinates !== structure.coordinates) throw new Error('equivalent matrix rejected');
reject('matrix-origin-mismatch', mutate((j) => {
const node = j.nodes.find((n) => n.name === 'c_table'); delete node.translation; delete node.rotation; delete node.scale;
node.matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 4, 5, 6, 1];
}));
const xml = structure.urdf;
reject('xml-syntax', () => parser.parseMachineUrdf(xml.slice(0, -15)));
reject('doctype', () => parser.parseMachineUrdf('<!DOCTYPE robot>' + xml));
reject('duplicate-coordinate', () => parser.parseMachineUrdf(xml.replace('coordinate="Y"', 'coordinate="X"')));
reject('negative-axis', () => parser.parseMachineUrdf(xml.replace('axis xyz="1 0 0"', 'axis xyz="-1 0 0"')));
reject('nonzero-rpy', () => parser.parseMachineUrdf(xml.replace('rpy="0 0 0"', 'rpy="0 0 0.1"')));
reject('nan-limit', () => parser.parseMachineUrdf(xml.replace('velocity="0.02"', 'velocity="NaN"')));
reject('mimic', () => parser.parseMachineUrdf(xml.replace('</joint>', '<mimic joint="x_joint"/></joint>')));
reject('unknown-profile', () => parser.parseMachineUrdf(xml.replace('trt-xyz-head/1', 'generic/1')));
reject('broken-tree', () => parser.parseMachineUrdf(xml.replace('parent link="base"', 'parent link="spindle_head"')));
const bundle = await (await import('/workspace/sim-case-loader.mjs')).loadSimulationCase({ path: `linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyz${id}-trt.ini`, baseUrl: '.' });
const tight = structuredClone(structure); tight.joints.find((j) => j.coordinate === 'X').limit.upper = .01;
reject('runtime-range-outside-model', () => api.configureImportedMachine(bundle, tight));
const slow = structuredClone(structure); slow.joints.find((j) => j.coordinate === 'X').limit.velocity = .001;
reject('runtime-speed-outside-model', () => api.configureImportedMachine(bundle, slow));
const changed = api.configureImportedMachine(bundle, structure);
const parsed = (await import('/workspace/trt-geometry.mjs')).readTrtGeometry(changed);
window.glbTestAsset && api.disposeMachineScene(window.glbTestAsset.scene);
window.glbTestAsset = await api.loadImportedMachine(bytes, `demo-${id}.glb`);
return { structure, hash: window.glbTestAsset.hash, results, writtenOffsets: parsed.offsets };
}, id);
report.parser.push({ id, rejected: descriptor.results });
for (const [key, value] of Object.entries(descriptor.structure.offsets)) assert.ok(Math.abs(descriptor.writtenOffsets[key] - value) < 1e-8);
const coordinates = descriptor.structure.coordinates;
// The real native inverse supplies the reference, not a duplicate JS RTCP formula.
const samples = [];
for (const tool_offset of [0, 17, -4]) {
const offsets = { ...descriptor.structure.offsets, tool_offset };
const native = new WasmXyzacKinematics(core, { coordinates, ...offsets, limits: Object.fromEntries([...coordinates.toLowerCase()].map((c) => [c, [-100000, 100000]])) });
for (const [tilt, c] of [[0, 0], [25, 0], [0, 65], [-35, 125], [42, -80], [-80, 270]]) {
const pose = { x: 32, y: 19, z: 28, a: 0, b: 0, c, u: 0, v: 0, w: 0, [coordinates[3].toLowerCase()]: tilt };
const inverse = native.inverseMode(1, pose); assert.equal(inverse.status, 'verified');
samples.push({ pose, offsets, joints: inverse.joints });
}
native.destroy();
}
const geometry = await page.evaluate(async (samples) => {
const api = await import('/src/imported-machine.ts');
const model = api.buildImportedMachine(window.glbTestAsset), rows = [];
for (const state of samples) {
model.update(state);
const point = model.workOrigin.position.clone().set(state.pose.x / 1000, state.pose.y / 1000, state.pose.z / 1000);
model.workOrigin.localToWorld(point);
const tip = model.toolTip.getWorldPosition(point.clone());
const error = point.distanceTo(tip);
if (error > 1e-8) throw new Error(`WASM / GLB mismatch: ${JSON.stringify({ state, point, tip, error })}`);
rows.push({ tilt: state.joints[3], c: state.joints[4], tool_offset: state.offsets.tool_offset, error });
}
// The two branches are independent: X moves the head but never the cradle.
model.update({ joints: [0, 0, 0, 0, 0], offsets: { tool_offset: 0 } });
const before = model.joints.rotate.getWorldPosition(model.toolTip.position.clone());
model.update({ joints: [50, 20, 10, 0, 0], offsets: { tool_offset: 0 } });
const after = model.joints.rotate.getWorldPosition(before.clone());
if (before.distanceTo(after) > 1e-10) throw new Error('table moved with XYZ');
model.dispose(); return rows;
}, samples);
// Confirm the Worker reparses the updated INI and receives every derived geometry value.
const definition = MACHINE_DEFINITIONS.find((m) => m.coordinates === coordinates);
const original = await loadSimulationCase({ path: definition.entrypoint, fetchImpl });
const config = toWorkerConfiguration(writeTrtGeometry(original, descriptor.structure.offsets));
config.kinematics.offsets.x_rot_point = 999;
const session = new SimSession(core, { sessionId: `glb-${id}` }); let seq = 0;
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `glb-${seq}`, payload });
send('HELLO'); send('SESSION_ATTACH');
const loaded = send('LOAD_WORKSPACE', { root_path: '/workspace', configuration: config }); assert.equal(loaded.kind, 'COMMAND_RESULT');
for (const [key, value] of Object.entries(descriptor.structure.offsets)) assert.ok(Math.abs(loaded.payload.panel_snapshot.kinematics.offsets[key] - value) < 1e-8);
const beforePath = await page.locator('main').getAttribute('data-axis-program-path');
await control('machine-glb-settings').click();
await control('machine-glb-file').setInputFiles(new URL(`web/react/public/machines/demo-${id}-xyz-head.glb`, root).pathname);
await settled();
assert.equal(await machine.getAttribute('data-machine-glb'), descriptor.hash, await page.locator('.machine-import').innerText());
assert.equal(await machine.getAttribute('data-xyzac-model-id'), `xyz${id}-trt`);
assert.equal(await page.locator('main').getAttribute('data-axis-program-path'), beforePath);
const imported = await sample();
for (const [key, value] of Object.entries(descriptor.structure.offsets)) assert.ok(Math.abs(imported.offsets[key] - value) < 1e-8);
// Rejected import must leave the live machine and its configuration untouched.
await control('machine-glb-file').setInputFiles({ name: 'broken.glb', mimeType: 'model/gltf-binary', buffer: Buffer.from('not a glb') }); await settled();
assert.match(await page.locator('.machine-import output').innerText(), /无效/);
assert.equal((await sample()).hash, descriptor.hash); assert.deepEqual((await sample()).offsets, imported.offsets);
await control(`machine-glb-${id}`).click(); await settled();
assert.equal((await sample()).hash, descriptor.hash);
assert.match(await page.locator('.machine-import output').innerText(), /模型及参数已应用/);
const downloadEvent = page.waitForEvent('download'); await page.getByRole('link', { name: `下载 ${id.toUpperCase()}` }).click(); const download = await downloadEvent;
assert.equal(createHash('sha256').update(await readFile(await download.path())).digest('hex'), descriptor.hash);
await page.locator('.model-dialog .machine-dialog-titlebar button').click();
await control('toolbar-power').click();
await page.waitForFunction(() => document.querySelector('[data-axis-control="machine-glb-import"]').disabled);
assert.equal(await control('machine-glb-ac').isDisabled(), true); assert.equal(await control('machine-glb-bc').isDisabled(), true);
await control('manual-home-all').click(); await page.waitForFunction(() => !document.querySelector('[data-axis-control="switchkins-1"]').disabled);
await jog('x', 0, 4); await jog('y', 1, 3);
await control('switchkins-1').click(); await page.waitForFunction(() => document.querySelector('[data-axis-control="switchkins-1"]').getAttribute('aria-pressed') === 'true');
await rendered(); const start = await sample();
const target = await page.evaluate(async (state) => { const m = (await import('/src/imported-machine.ts')).buildImportedMachine(window.glbTestAsset); m.update(state); const p = m.workOrigin.worldToLocal(m.toolTip.getWorldPosition(m.toolTip.position.clone())).toArray(); m.dispose(); return p; }, start);
const jogs = [];
for (const axis of [coordinates[3].toLowerCase(), 'c', coordinates[3].toLowerCase(), 'c']) {
const row = await jog(axis, axis === 'c' ? 4 : 3, 2);
assert.ok(row.after.joints.slice(0, 3).some((v, i) => Math.abs(v - row.before.joints[i]) > 1e-6));
const check = await page.evaluate(async ({ state, target }) => {
const m = (await import('/src/imported-machine.ts')).buildImportedMachine(window.glbTestAsset); m.update(state);
const p = m.workOrigin.localToWorld(m.toolTip.position.clone().fromArray(target)), tip = m.toolTip.getWorldPosition(p.clone());
const result = { tcp_error: p.distanceTo(tip), render_error: tip.distanceTo(p.fromArray(state.tip)) }; m.dispose(); return result;
}, { state: row.after, target });
assert.ok(check.tcp_error < 1e-7); assert.ok(check.render_error < 1e-7);
jogs.push({ ...row, ...check });
}
const pixels = await machine.locator('canvas[data-engine]').evaluate((canvas) => { const gl = canvas.getContext('webgl2'), data = new Uint8Array(canvas.width * canvas.height * 4); gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, data); const colors = new Set(); for (let i = 0; i < data.length; i += 32) colors.add(`${data[i]},${data[i + 1]},${data[i + 2]}`); return { colors: colors.size, width: canvas.width, height: canvas.height }; });
assert.ok(pixels.colors > 100);
await page.locator('.axis-preview-machine').screenshot({ path: new URL(`${id}-imported.png`, output).pathname });
await control('toolbar-power').click(); await page.waitForFunction(() => !document.querySelector('[data-axis-control="machine-glb-import"]').disabled);
report.machines.push({ id, hash: descriptor.hash, coordinates, offsets: descriptor.structure.offsets, program_preserved: beforePath, geometry, jogs, pixels, worker_derived_offsets: loaded.payload.panel_snapshot.kinematics.offsets });
}
for (const [width, height] of [[1440, 1100], [390, 844]]) {
await page.setViewportSize({ width, height }); await page.locator('.machine-import').scrollIntoViewIfNeeded();
await control('machine-glb-settings').click();
const bounds = await page.locator('.model-dialog button, .model-dialog a').evaluateAll((nodes) => nodes.map((n) => { const r = n.getBoundingClientRect(); return { x: r.x, right: r.right, width: r.width }; }));
assert.ok(bounds.every((r) => r.width > 0 && r.x >= -1 && r.right <= width + 1));
await page.screenshot({ path: new URL(`viewport-${width}.png`, output).pathname, fullPage: true });
report.viewports.push({ width, height, bounds });
await page.keyboard.press('Escape');
}
// Reloading a compatible INI updates the effective structure while preserving the imported asset.
const importedHash = await machine.getAttribute('data-machine-glb');
await control('geometry-settings').click();
await control('geometry-reload').click(); await settled(); assert.equal(await machine.getAttribute('data-machine-glb'), importedHash);
assert.deepEqual(errors, []); report.status = 'verified';
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); await page.screenshot({ path: new URL('failure.png', output).pathname, fullPage: true }); throw error; }
finally { report.browser_errors = errors; await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); }
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, machines: report.machines.map((m) => ({ id: m.id, geometry_samples: m.geometry.length, jogs: m.jogs.length })), rejected: report.parser.reduce((sum, p) => sum + p.rejected.length, 0) }));

View File

@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import createModule from '../build-wasm/linuxcnc-sim-core.js';
import { SimSession, CONTEXT_ID } from '../web/worker/sim-session.mjs';
import { loadSimulationCase, toWorkerConfiguration } from '../web/workspace/sim-case-loader.mjs';
import { writeTrtGeometry } from '../web/workspace/trt-geometry.mjs';
import { MACHINE_DEFINITIONS } from '../web/workspace/machine-definitions.mjs';
const root = new URL('../', import.meta.url), output = process.env.GLB_EVIDENCE_DIR ? pathToFileURL(`${process.env.GLB_EVIDENCE_DIR.replace(/\/$/, '')}/`) : new URL('coverage/glb-machine/', root);
const gantry = process.env.GLB_MACHINE_VARIANT === 'gantry';
const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } };
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage();
const report = { report_id: 'glb-machine-five-axis-motion-v1', status: 'running', machines: [] };
try {
await page.goto(process.env.XYZAC_PANEL_URL ?? 'http://127.0.0.1:5174/');
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
for (const definition of MACHINE_DEFINITIONS) {
const id = definition.coordinates.slice(3).toLowerCase();
const structure = await page.evaluate(async ({ id, gantry }) => {
const api = await import('/src/imported-machine.ts');
if (window.motionAsset) api.disposeMachineScene(window.motionAsset.scene);
window.motionAsset = await api.loadImportedMachine(await (await fetch(`/machines/demo-${id}-${gantry ? 'gantry' : 'xyz-head'}.glb`)).arrayBuffer(), `${id}.glb`);
return window.motionAsset.structure;
}, { id, gantry });
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
const session = new SimSession(core, { sessionId: `glb-motion-${id}` }); let seq = 0;
const send = (kind, payload = {}) => {
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `motion-${seq}`, payload });
assert.notEqual(result.kind, 'ERROR', `${kind}: ${JSON.stringify(result.payload)}`); return result;
};
send('HELLO'); send('SESSION_ATTACH');
const bundle = writeTrtGeometry(await loadSimulationCase({ path: definition.entrypoint, fetchImpl }), structure.offsets);
send('LOAD_WORKSPACE', { root_path: '/workspace', configuration: toWorkerConfiguration(bundle) });
const tilt = definition.coordinates[3];
const source = `G21 G90 G94\nG0 X20 Y10 Z20 ${tilt}0 C0\nM428\nG1 X35 Y24 Z32 ${tilt}25 C40 F900\nG1 X24 Y16 Z24 ${tilt}-20 C-30 F900\nM2\n`;
send('PROGRAM_LOAD', { source, path: `five-axis-${id}.ngc` });
send('POWER', { active: true }); send('HOME', { all: true });
for (let tick = 0; tick < 500 && !session.wasmHomeLimit.read().state.homed; tick++) send('SIM_STEP', { cycles: 1 });
assert.equal(session.wasmHomeLimit.read().state.homed, true);
send('PROGRAM_RUN');
const samples = []; let simultaneousTicks = 0, previous, result;
for (let tick = 0; tick < 3000; tick++) {
result = send('SIM_STEP', { cycles: 10 });
const panel = result.payload.panel_snapshot;
const joints = panel.visual.joints;
if (panel.kinematics.active_type === 1 && joints?.length === 5) {
if (previous && joints.every((v, i) => Math.abs(v - previous[i]) > 1e-8)) simultaneousTicks++;
samples.push({ tick: result.simulation_tick, pose: panel.visual.pose, joints, offsets: panel.kinematics.offsets });
previous = joints;
}
if (result.payload.program.program_status === 'completed' && result.payload.wasm_motion.state.queue_depth === 0) break;
}
assert.equal(result.payload.program.program_status, 'completed');
assert.ok(simultaneousTicks > 10, `only ${simultaneousTicks} simultaneously moving ticks`);
assert.ok(samples.length > 10);
const projection = await page.evaluate(async (samples) => {
const model = (await import('/src/imported-machine.ts')).buildImportedMachine(window.motionAsset);
let maxError = 0;
for (const sample of samples) {
model.update(sample);
const point = model.workOrigin.localToWorld(model.toolTip.position.clone().set(sample.pose.x / 1000, sample.pose.y / 1000, sample.pose.z / 1000));
const tip = model.toolTip.getWorldPosition(point.clone()); maxError = Math.max(maxError, point.distanceTo(tip));
}
model.dispose(); return { samples: samples.length, max_error_view_units: maxError };
}, samples);
assert.ok(projection.max_error_view_units < 1e-7, JSON.stringify(projection));
report.machines.push({ coordinates: structure.coordinates, source, simultaneous_ticks: simultaneousTicks, ...projection, trajectory: samples });
}
report.status = 'verified';
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; }
finally { await writeFile(new URL('motion.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); }
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, machines: report.machines.map(({ coordinates, simultaneous_ticks, samples, max_error_view_units }) => ({ coordinates, simultaneous_ticks, samples, max_error_view_units })) }));

View File

@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import createModule from '../build-wasm/linuxcnc-sim-core.js';
import { WasmXyzacKinematics } from '../web/worker/wasm-xyzac-kinematics.mjs';
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const root = new URL('../', import.meta.url), output = process.env.GLB_EVIDENCE_DIR ? pathToFileURL(`${process.env.GLB_EVIDENCE_DIR.replace(/\/$/, '')}/`) : new URL('coverage/glb-machine/geometry-edit/', root);
const gantry = process.env.GLB_MACHINE_VARIANT === 'gantry';
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.addInitScript(() => { window.showSaveFilePicker = undefined; });
const errors = []; page.on('pageerror', (e) => errors.push(e.message));
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const settled = () => page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false' && document.querySelector('.trt-geometry')?.getAttribute('aria-busy') === 'false');
const machine = page.locator('.xyzac-machine-view');
const snapshot = () => machine.evaluate((e) => ({ hash: e.dataset.machineGlb, filename: e.dataset.xyzacModelSourcePath, offsets: JSON.parse(e.dataset.xyzacOffsets), joints: JSON.parse(e.dataset.xyzacJoints), tip: JSON.parse(e.dataset.xyzacToolTip) }));
const report = { report_id: 'imported-geometry-browser-v1', status: 'running', machines: [] };
try {
await page.goto('http://127.0.0.1:5174/'); await settled();
for (const id of ['ac', 'bc']) {
await control('machine-glb-settings').click(); await control('machine-glb-file').setInputFiles(new URL(`web/react/public/machines/demo-${id}-${gantry ? 'gantry' : 'xyz-head'}.glb`, root).pathname); await settled();
await page.keyboard.press('Escape');
const original = await snapshot(), beforePath = await page.locator('main').getAttribute('data-axis-program-path');
assert.ok(original.hash);
await control('geometry-settings').click();
const edits = { x_rot_point: 31, y_rot_point: -9, z_rot_point: 15, x_offset: id === 'bc' ? 27 : 0, y_offset: id === 'ac' ? 23 : 0, z_offset: -34 };
for (const [key, value] of Object.entries(edits)) if (key !== (id === 'ac' ? 'x_offset' : 'y_offset')) await page.getByLabel(key.replaceAll('_', '-'), { exact: true }).fill(String(value));
await control('geometry-apply').click(); await settled();
const applied = await snapshot();
assert.equal(applied.hash, original.hash, 'applying geometry must retain imported GLB');
assert.equal(applied.filename, original.filename);
for (const [key, value] of Object.entries(edits)) assert.ok(Math.abs(applied.offsets[key] - value) < 1e-8);
assert.equal(await page.locator('main').getAttribute('data-axis-program-path'), beforePath);
const downloadEvent = page.waitForEvent('download'); await control('geometry-save').click(); const exported = await readFile(await (await downloadEvent).path(), 'utf8');
assert.match(exported, /x-rot-point\s+31/);
await page.keyboard.press('Escape');
await control('machine-glb-settings').click();
if (!(await page.locator('[data-machine-structure]').evaluate((e) => e.open))) await page.locator('[data-machine-structure] summary').click();
const details = await page.locator('[data-machine-structure]').innerText(); assert.match(details, /31\.000 \/ -9\.000 \/ 15\.000/); assert.match(details, /-34\.000/);
await page.keyboard.press('Escape');
await page.locator('.axis-preview-machine').screenshot({ path: new URL(`${id}-edited.png`, output).pathname });
// Exercise the actual imported hierarchy with native IK at the edited offsets.
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
const samples = [], coordinates = `XYZ${id.toUpperCase()}`;
for (const tool_offset of [0, 17, -4]) {
const offsets = { ...edits, tool_offset };
const native = new WasmXyzacKinematics(core, { coordinates, ...offsets, limits: Object.fromEntries([...coordinates.toLowerCase()].map((a) => [a, [-100000, 100000]])) });
for (const [tilt, c] of [[0, 0], [25, 0], [0, 65], [-35, 125], [42, -80], [-80, 270]]) {
const pose = { x: 32, y: 19, z: 28, a: 0, b: 0, c, u: 0, v: 0, w: 0, [id[0]]: tilt };
const inverse = native.inverseMode(1, pose); assert.equal(inverse.status, 'verified'); samples.push({ pose, joints: inverse.joints, offsets });
}
native.destroy();
}
const projection = await page.evaluate(async ({ id, edits, samples, gantry }) => {
const api = await import('/src/imported-machine.ts');
const geometry = await import('/workspace/trt-geometry.mjs');
const loader = await import('/workspace/sim-case-loader.mjs');
const parser = await import('/src/machine-urdf.ts');
const asset = await api.loadImportedMachine(await (await fetch(`/machines/demo-${id}-${gantry ? 'gantry' : 'xyz-head'}.glb`)).arrayBuffer(), `${id}.glb`);
const originalUrdf = asset.structure.urdf;
const bundle = await loader.loadSimulationCase({ path: `linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyz${id}-trt.ini`, baseUrl: '.' });
const adjusted = api.updateImportedMachineGeometry(asset, geometry.writeTrtGeometry(bundle, edits));
if (asset.scene !== adjusted.scene || asset.hash !== adjusted.hash || asset.structure.urdf !== originalUrdf) throw new Error('source asset changed');
const parsed = parser.parseMachineUrdf(adjusted.structure.urdf);
for (const [key, value] of Object.entries(edits)) if (Math.abs(parsed.offsets[key] - value) > 1e-8) throw new Error(`URDF drift ${key}`);
const model = api.buildImportedMachine(adjusted);
const meshes = []; model.machine.traverse((n) => { if (n.isMesh) meshes.push(n.name); });
if (!meshes.some((name) => name.startsWith(gantry ? 'gantry_post_' : 'portal_column_'))) throw new Error('original mesh lost');
const checks = [];
for (const state of samples) {
model.update(state);
const point = model.workOrigin.localToWorld(model.toolTip.position.clone().set(state.pose.x / 1000, state.pose.y / 1000, state.pose.z / 1000));
const tip = model.toolTip.getWorldPosition(point.clone()); const error = tip.distanceTo(point);
if (error > 1e-8) throw new Error(`IK/model mismatch ${error}`); checks.push(error);
}
const renamed = structuredClone(asset.structure);
renamed.urdf = renamed.urdf.replaceAll('tilt_joint', 'custom_tilt').replaceAll('c_joint', 'custom_spin').replaceAll('work_mount', 'custom_work');
renamed.joints = parser.parseMachineUrdf(renamed.urdf).joints;
const custom = api.updateImportedMachineGeometry({ ...asset, structure: renamed }, geometry.writeTrtGeometry(bundle, edits));
if (!custom.structure.joints.some((j) => j.name === 'custom_tilt')) throw new Error('relies on fixture joint names');
let mismatch = false;
const other = await loader.loadSimulationCase({ path: `linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyz${id === 'ac' ? 'bc' : 'ac'}-trt.ini`, baseUrl: '.' });
try { api.updateImportedMachineGeometry(asset, other); } catch { mismatch = true; }
if (!mismatch) throw new Error('incompatible machine not rejected');
const repeat = api.updateImportedMachineGeometry(adjusted, geometry.writeTrtGeometry(bundle, edits));
if (repeat !== adjusted) throw new Error('unchanged geometry should reuse asset');
model.dispose(); api.disposeMachineScene(asset.scene);
return { checks, mesh_count: meshes.length, source_preserved: true, renamed_joint_supported: true, incompatible_rejected: true };
}, { id, edits, samples, gantry });
await control('geometry-settings').click();
// Bad numeric input must leave the successfully applied model and parameters intact.
await page.getByLabel('x-rot-point', { exact: true }).fill(''); await control('geometry-apply').click(); await settled();
assert.match(await page.locator('.trt-geometry footer output').innerText(), /trt_geometry_number/);
assert.equal((await snapshot()).hash, original.hash); assert.deepEqual((await snapshot()).offsets, applied.offsets);
// Reload the INI, then re-import the saved INI: both preserve the model.
await control('geometry-reload').click(); await settled(); assert.equal((await snapshot()).hash, original.hash);
await page.locator('.trt-geometry input[type="file"]').setInputFiles({ name: `xyz${id}-trt.ini`, mimeType: 'text/plain', buffer: Buffer.from(exported) });
await page.waitForFunction(() => document.querySelector('input[aria-label="x-rot-point"]').value === '31');
await control('geometry-apply').click(); await settled(); assert.equal((await snapshot()).hash, original.hash);
assert.deepEqual((await snapshot()).offsets, applied.offsets);
await page.keyboard.press('Escape');
await control('toolbar-power').click(); await control('manual-home-all').click();
await page.waitForFunction(() => !document.querySelector('[data-axis-control="switchkins-1"]').disabled);
await control('switchkins-1').click();
await control('tab-manual').click(); await control(`manual-axis-${id[0]}`).check();
const beforeJog = await snapshot(), b = await control('manual-jog-positive').boundingBox();
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2); await page.mouse.down();
try { await page.waitForFunction((initial) => JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[3] > initial + 2, beforeJog.joints[3]); } finally { await page.mouse.up(); }
await page.waitForFunction(() => document.querySelector('.command-status')?.textContent?.includes('JOG_STOP acknowledged'));
await page.waitForFunction(() => { const e = document.querySelector('.xyzac-machine-view'); return e.dataset.xyzacTick === e.dataset.xyzacRenderedTick; });
const afterJog = await snapshot(); assert.ok(afterJog.joints.slice(0, 3).some((v, i) => Math.abs(v - beforeJog.joints[i]) > 1e-5));
const tcpError = await page.evaluate(async ({ id, edits, beforeJog, afterJog, gantry }) => {
const api = await import('/src/imported-machine.ts'), loader = await import('/workspace/sim-case-loader.mjs'), geometry = await import('/workspace/trt-geometry.mjs');
const asset = await api.loadImportedMachine(await (await fetch(`/machines/demo-${id}-${gantry ? 'gantry' : 'xyz-head'}.glb`)).arrayBuffer(), id);
const bundle = await loader.loadSimulationCase({ path: `linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyz${id}-trt.ini`, baseUrl: '.' });
const model = api.buildImportedMachine(api.updateImportedMachineGeometry(asset, geometry.writeTrtGeometry(bundle, edits)));
model.update(beforeJog); const target = model.workOrigin.worldToLocal(model.toolTip.getWorldPosition(model.toolTip.position.clone()));
model.update(afterJog); const tip = model.toolTip.getWorldPosition(target.clone()), point = model.workOrigin.localToWorld(target.clone());
const error = tip.distanceTo(point), rendered = tip.distanceTo(point.fromArray(afterJog.tip));
model.dispose(); api.disposeMachineScene(asset.scene); return { error, rendered };
}, { id, edits, beforeJog, afterJog, gantry });
assert.ok(tcpError.error < 1e-8 && tcpError.rendered < 1e-8);
await control('geometry-settings').click(); assert.ok(await page.getByLabel('x-rot-point', { exact: true }).isDisabled()); await page.keyboard.press('Escape');
await control('toolbar-power').click(); await page.waitForFunction(() => !document.querySelector('[data-axis-control="machine-glb-import"]').disabled);
report.machines.push({ id, original, applied, edits, projection, tcpError, model_retained_on_reload_and_ini_import: true });
}
// Deliberately selecting a different machine still discards an incompatible GLB.
await control('machine-type-select').selectOption({ label: 'XYZAC TRT' }); await settled(); assert.equal((await snapshot()).hash, '');
assert.deepEqual(errors, []); report.status = 'verified';
} catch (e) { report.status = 'failed'; report.error = String(e.stack ?? e); await page.screenshot({ path: new URL('failure.png', output).pathname }); throw e; }
finally { report.browser_errors = errors; await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); }
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, machines: report.machines.map((m) => ({ id: m.id, pose_samples: m.projection.checks.length, tcpError: m.tcpError })) }));

View File

@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const output = new URL('../coverage/glb-machine/jog-limits/', import.meta.url);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.addInitScript(() => {
window.jogErrors = [];
const OriginalWorker = window.Worker;
window.Worker = class extends OriginalWorker {
constructor(...args) {
super(...args);
this.addEventListener('message', ({ data }) => {
if (data.kind === 'ERROR') window.jogErrors.push({ seq: data.seq, ...data.payload });
window.jogLastResponse = data;
});
}
};
});
const errors = []; page.on('pageerror', (e) => errors.push(e.message));
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const sample = () => page.locator('.xyzac-machine-view').evaluate((e) => ({ joints: JSON.parse(e.dataset.xyzacJoints), tip: JSON.parse(e.dataset.xyzacToolTip), tick: Number(e.dataset.xyzacTick) }));
const queueEmpty = () => page.waitForFunction(() => window.jogLastResponse?.payload?.wasm_motion?.state?.queue_depth === 0 || window.jogLastResponse?.payload?.panel_snapshot?.wasm_motion?.state?.queue_depth === 0);
async function seed(block, angle) {
await control('tab-mdi').click(); await control('mdi-command').fill(block); await control('mdi-submit').click();
await page.waitForFunction((angle) => Math.abs(JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[3] - angle) < 1e-7, angle);
await queueEmpty(); await control('tab-manual').click();
}
async function hold(axis, direction, until) {
await control(`manual-axis-${axis}`).check();
const box = await control(direction > 0 ? 'manual-jog-positive' : 'manual-jog-negative').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down();
try { await until(); }
finally { await page.mouse.up(); }
await page.waitForFunction(() => document.querySelector('.command-status')?.textContent?.includes('JOG_STOP acknowledged'));
}
const report = { report_id: 'jog-limit-browser-v1', status: 'running', machines: [] };
try {
await page.goto('http://127.0.0.1:5174/');
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
for (const id of ['ac', 'bc']) {
await control('machine-glb-settings').click(); await control(`machine-glb-${id}`).click();
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false'); await page.keyboard.press('Escape');
await control('toolbar-power').click(); await control('manual-home-all').click();
await page.waitForFunction(() => !document.querySelector('[data-axis-control="switchkins-1"]').disabled);
const axis = id[0];
await seed(`G21 G90 G0 X0 Y0 Z30 ${axis.toUpperCase()}49 C20`, 49);
await hold(axis, 1, async () => {
await page.waitForFunction(() => Math.abs(JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[3] - 50) < 1e-7);
await queueEmpty();
});
const atLimit = await sample(); assert.ok(atLimit.joints[3] <= 50 + 1e-8);
await hold(axis, -1, () => page.waitForFunction(() => JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[3] < 49));
const retreat = await sample(); assert.ok(retreat.joints[3] < atLimit.joints[3]);
await seed(`G90 G0 X190 Y0 Z30 ${axis.toUpperCase()}0 C0`, 0);
await control('switchkins-1').click();
const count = await page.evaluate(() => window.jogErrors.length);
await hold('c', 1, () => page.waitForFunction((count) => window.jogErrors.length > count, count));
const fault = await page.evaluate((count) => window.jogErrors[count], count);
assert.equal(fault.code, 'motion_limit'); assert.equal(fault.reason, 'motion_joint_limit');
await queueEmpty(); const stopped = await sample();
assert.ok(stopped.joints[1] <= 100 + 1e-7);
await page.waitForTimeout(150); assert.deepEqual((await sample()).joints, stopped.joints);
await hold('c', -1, () => page.waitForFunction((c) => JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[4] < c - 1, stopped.joints[4]));
const resumed = await sample(); assert.ok(resumed.joints[4] < stopped.joints[4]);
for (let i = 0; i < 4; i++) {
const before = await sample(), dir = i % 2 ? -1 : 1;
await hold('c', dir, () => page.waitForFunction(({ c, dir }) => (JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[4] - c) * dir > .2, { c: before.joints[4], dir }));
}
assert.doesNotMatch(await page.locator('.axis-window-header').innerText(), /ERROR/);
const pixels = await page.locator('.xyzac-machine-view canvas').evaluate((canvas) => {
const gl = canvas.getContext('webgl2'), bytes = new Uint8Array(canvas.width * canvas.height * 4), colors = new Set();
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, bytes);
for (let i = 0; i < bytes.length; i += 32) colors.add(`${bytes[i]},${bytes[i + 1]},${bytes[i + 2]}`);
return colors.size;
});
assert.ok(pixels > 100); assert.notDeepEqual(resumed.tip, stopped.tip);
await page.screenshot({ path: new URL(`${id}-recovered.png`, output).pathname });
await control('toolbar-power').click();
await page.waitForFunction(() => !document.querySelector('[data-axis-control="machine-glb-import"]').disabled);
report.machines.push({ id, atLimit, retreat, stopped, resumed, fault: { code: fault.code, reason: fault.reason }, pixels });
}
const faults = await page.evaluate(() => window.jogErrors.map((e) => ({ code: e.code, reason: e.reason })));
assert.ok(faults.every((e) => e.code === 'motion_limit'), JSON.stringify(faults));
assert.deepEqual(errors, []); report.status = 'verified';
} catch (e) { report.status = 'failed'; report.error = String(e.stack ?? e); await page.screenshot({ path: new URL('failure.png', output).pathname }); throw e; }
finally { report.page_errors = errors; await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); }
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, machines: report.machines.map((m) => m.id) }));

View File

@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const output = new URL('../coverage/glb-machine/dialogs/', import.meta.url); await mkdir(output, { recursive: true });
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = []; page.on('pageerror', (e) => errors.push(e.message));
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const settled = () => page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
const report = { report_id: 'machine-dialog-browser-v1', status: 'running', layouts: [], dialogs: [] };
const bounds = (selector) => page.locator(selector).boundingBox();
async function drag(dialog, dx, dy) {
const title = await page.locator(`${dialog} .machine-dialog-titlebar strong`).boundingBox();
await page.mouse.move(title.x + 15, title.y + title.height / 2); await page.mouse.down();
await page.mouse.move(title.x + 15 + dx, title.y + title.height / 2 + dy, { steps: 8 }); await page.mouse.up();
}
async function within(selector) {
const b = await bounds(selector); const viewport = page.viewportSize();
assert.ok(b.x >= 7 && b.y >= 7 && b.x + b.width <= viewport.width - 7 && b.y + b.height <= viewport.height - 7, JSON.stringify({ b, viewport }));
return b;
}
try {
await page.goto('http://127.0.0.1:5174/'); await settled();
assert.equal(await page.locator('dialog[open]').count(), 0);
await control('machine-glb-settings').click();
await control('machine-glb-bc').click(); await settled();
assert.ok(await page.locator('.xyzac-machine-view').getAttribute('data-machine-glb'));
await page.keyboard.press('Escape');
assert.equal(await page.locator('dialog[open]').count(), 0);
assert.equal(await control('machine-glb-settings').evaluate((e) => e === document.activeElement), true);
for (const [button, selector] of [['machine-glb-settings', '.model-dialog'], ['geometry-settings', '.geometry-dialog']]) {
const canvasBefore = await bounds('.xyzac-machine-view canvas');
await control(button).click();
const before = await within(selector);
await drag(selector, 110, 60); const after = await within(selector);
assert.ok(Math.abs(after.x - before.x - 110) < 2 && Math.abs(after.y - before.y - 60) < 2);
assert.deepEqual(await bounds('.xyzac-machine-view canvas'), canvasBefore);
await page.keyboard.press('F2');
assert.equal(await control('machine-glb-import').isDisabled(), false, 'dialog key must not power on the machine');
for (let i = 0; i < 12; i++) { await page.keyboard.press('Tab'); assert.ok(await page.locator(selector).evaluate((e) => e.contains(document.activeElement))); }
await drag(selector, -2000, -2000); await within(selector);
await drag(selector, 4000, 4000); await within(selector);
await page.screenshot({ path: new URL(`${button}-dragged.png`, output).pathname });
await page.keyboard.press('Escape'); assert.equal(await page.locator('dialog[open]').count(), 0);
assert.equal(await control(button).evaluate((e) => e === document.activeElement), true);
report.dialogs.push({ button, before, after, focus_contained: true, keyboard_ignored: true, edge_clamped: true });
}
await control('geometry-settings').click(); await page.getByLabel('x-rot-point', { exact: true }).fill('31');
await page.keyboard.press('Escape'); await control('geometry-settings').click();
assert.equal(await page.getByLabel('x-rot-point', { exact: true }).inputValue(), '31');
await page.keyboard.press('Escape');
await control('toolbar-power').click();
await page.waitForFunction(() => document.querySelector('[data-axis-control="machine-glb-import"]').disabled);
await control('geometry-settings').click(); assert.ok(await page.getByLabel('x-rot-point', { exact: true }).isDisabled()); await page.keyboard.press('Escape');
await control('toolbar-power').click();
for (const [width, height] of [[2048, 859], [1440, 900], [390, 844]]) {
await page.setViewportSize({ width, height });
await page.locator('.xyzac-machine-view').scrollIntoViewIfNeeded();
await page.waitForTimeout(150);
const machine = await bounds('.xyzac-machine-view'), path = await bounds('.axis-preview-toolpath'), pane = await bounds('.axis-preview-machine');
assert.ok(machine.height >= 250, JSON.stringify(machine));
assert.ok(machine.height / pane.height > .85, JSON.stringify({ machine, pane }));
if (width > 900) assert.ok(path.height - machine.height < 50, JSON.stringify({ machine, path }));
await page.locator('.axis-preview-machine').screenshot({ path: new URL(`preview-${width}.png`, output).pathname });
await control('geometry-settings').click(); await within('.geometry-dialog');
await page.screenshot({ path: new URL(`parameters-${width}.png`, output).pathname });
const fields = await page.locator('.geometry-dialog input:not([hidden])').evaluateAll((nodes) => nodes.map((e) => { const r = e.getBoundingClientRect(); return { x: r.x, right: r.right }; }));
assert.ok(fields.every((r) => r.x >= 0 && r.right <= width));
await page.keyboard.press('Escape');
await control('machine-glb-settings').click(); await within('.model-dialog');
if (width === 390) {
const cdp = await page.context().newCDPSession(page);
const b = await bounds('.model-dialog .machine-dialog-titlebar strong');
const before = await bounds('.model-dialog');
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: b.x + 10, y: b.y + 8 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: b.x + 10, y: b.y + 58 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const after = await within('.model-dialog'); assert.ok(after.y - before.y > 30);
report.touch_drag = { before, after };
}
await page.keyboard.press('Escape');
report.layouts.push({ width, height, machine, path, pane });
}
await page.setViewportSize({ width: 1440, height: 900 }); await control('geometry-settings').click(); await drag('.geometry-dialog', 150, 160);
await page.setViewportSize({ width: 390, height: 500 }); await page.waitForTimeout(150); await within('.geometry-dialog');
await page.keyboard.press('Escape');
assert.deepEqual(errors, []); report.status = 'verified';
} catch (e) { report.status = 'failed'; report.error = String(e.stack ?? e); await page.screenshot({ path: new URL('failure.png', output).pathname }); throw e; }
finally { report.browser_errors = errors; await writeFile(new URL('dialog.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); }
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, layouts: report.layouts.map(({ width, machine }) => ({ width, canvas_height: machine.height })) }));

View File

@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
import { mkdir, writeFile } from "node:fs/promises";
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
import { GLTFExporter } from "../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js";
globalThis.FileReader = class {
readAsArrayBuffer(blob) { blob.arrayBuffer().then((result) => { this.result = result; this.onloadend?.(); }); }
readAsDataURL(blob) { blob.arrayBuffer().then((result) => { this.result = `data:${blob.type};base64,${Buffer.from(result).toString("base64")}`; this.onloadend?.(); }); }
};
const partGlb = async (index) => {
const scene = new THREE.Scene(); scene.name = `part_${index}`;
const mesh = new THREE.Mesh(new THREE.BoxGeometry(.08 + index * .004, .06, .05), new THREE.MeshStandardMaterial({ color: new THREE.Color().setHSL(index / 8, .55, .5) })); mesh.name = `mesh_${index}`; scene.add(mesh);
const bytes = await new GLTFExporter().parseAsync(scene, { binary: true, trs: true, onlyVisible: false }); mesh.geometry.dispose(); mesh.material.dispose(); return Buffer.from(bytes);
};
const fixtures = await Promise.all(Array.from({ length: 8 }, async (_, index) => ({ name: `${String(index + 1).padStart(2, "0")}-part.glb`, mimeType: "model/gltf-binary", buffer: await partGlb(index) })));
const modulePath = process.env.PLAYWRIGHT_MODULE;
if (!modulePath) { console.log(JSON.stringify({ schema: "machine-manager-browser.v1", status: "blocked", code: "playwright_unavailable" })); process.exit(0); }
const imported = await import(pathToFileURL(modulePath).href); const playwright = imported.default ?? imported;
const output = new URL("../coverage/glb-machine-manager/", import.meta.url); await mkdir(output, { recursive: true });
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); const errors = [];
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); });
const control = (id) => page.locator(`[data-axis-control="${id}"]`); const main = page.locator("main.axis-app");
const production = process.env.MACHINE_MANAGER_SOURCE_MODULES === "0";
const report = { schema: "machine-manager-browser.v1", report_id: production ? "GLB-MGR-E02" : "GLB-MGR-E01", mode: production ? "production-preview" : "development-source", status: "running", checks: [] };
try {
await page.goto(process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5174/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false" && document.querySelector("main.axis-app")?.dataset.axisStateSource === "linuxcnc-wasm", null, { timeout: 45000 });
await control("machine-manager-settings").click(); assert.equal(await page.locator(".machine-manager-dialog[open]").count(), 1); assert.equal(await page.locator("[data-machine-manager-preview] canvas").count(), 1);
await control("machine-manager-files").setInputFiles(fixtures);
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "8");
assert.equal(await page.locator(".machine-part-list > div").count(), 8); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8");
report.checks.push("eight GLB parts imported and rendered in the Three.js editor");
await control("machine-manager-show-origin").uncheck(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "7");
const translation = page.locator("fieldset.machine-vector").filter({ hasText: "部件平移" }); await translation.getByLabel("X", { exact: true }).fill("10");
const rotation = page.locator("fieldset.machine-vector").filter({ hasText: "部件旋转" }); await rotation.getByLabel("Z", { exact: true }).fill("15");
await control("machine-manager-rotate").click(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-transform-mode"), "rotate");
await page.locator(".machine-manager-dialog").screenshot({ path: new URL("manager-ac-config.png", output).pathname });
report.checks.push("origin visibility, numeric translation/rotation and TransformControls mode update");
const calibrations = page.locator(".machine-manager-calibration fieldset.machine-vector");
const cOrigin = calibrations.filter({ hasText: "C 轴原点" }); await cOrigin.getByLabel("X", { exact: true }).fill("24"); await cOrigin.getByLabel("Y", { exact: true }).fill("-12"); await cOrigin.getByLabel("Z", { exact: true }).fill("8");
const offset = calibrations.filter({ hasText: "A 到 C 轴偏置" }); await offset.getByLabel("X", { exact: true }).fill("0"); await offset.getByLabel("Y", { exact: true }).fill("18"); await offset.getByLabel("Z", { exact: true }).fill("-45");
const zeros = page.locator("fieldset.machine-zero"); await zeros.getByLabel("X", { exact: true }).fill("5"); await zeros.getByLabel("A", { exact: true }).fill("10"); await zeros.getByLabel("C", { exact: true }).fill("20");
assert.equal(await control("machine-manager-export").isDisabled(), false); const downloadPromise = page.waitForEvent("download"); await control("machine-manager-export").click(); const download = await downloadPromise; assert.equal(download.suggestedFilename(), "managed-xyzac.glb");
report.checks.push("validated assembly exports a self-contained managed GLB");
assert.equal(await control("machine-manager-apply").isDisabled(), false); await control("machine-manager-apply").click();
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineManagerSchema === "linuxcnc.machine-manager/1" && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", null, { timeout: 45000 });
const geometry = JSON.parse(await page.locator(".xyzac-machine-view").getAttribute("data-machine-manager-geometry"));
assert.deepEqual(geometry.cOriginMm, [24, -12, 8]); assert.deepEqual(geometry.tiltOffsetMm, [0, 18, -45]); assert.equal(geometry.physicalZero.x, 5); assert.equal(geometry.physicalZero.a, 10); assert.equal(geometry.physicalZero.c, 20); assert.equal(geometry.parts.length, 8); assert.equal(geometry.parts[0].translationMm[0], 10);
assert.match(await page.locator(".xyzac-machine-view").getAttribute("data-xyzac-model-source-path"), /^managed-xyzac\.glb$/);
report.checks.push("validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData");
await page.keyboard.press("Escape");
const xyzbcCase = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"; await control("workspace-case-select").selectOption(xyzbcCase);
await page.waitForFunction((path) => document.querySelector('[data-axis-control="workspace-case-select"]')?.value === path && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", xyzbcCase, { timeout: 45000 });
await control("machine-manager-settings").click(); assert.match(await page.locator(".machine-manager-dialog .machine-dialog-titlebar").textContent(), /XYZBC/);
const bcCalibrations = page.locator(".machine-manager-calibration fieldset.machine-vector"); const bcOrigin = bcCalibrations.filter({ hasText: "C 轴原点" }); await bcOrigin.getByLabel("X", { exact: true }).fill("-18"); await bcOrigin.getByLabel("Y", { exact: true }).fill("14"); await bcOrigin.getByLabel("Z", { exact: true }).fill("12");
const bcOffset = bcCalibrations.filter({ hasText: "B 到 C 轴偏置" }); await bcOffset.getByLabel("X", { exact: true }).fill("22"); await bcOffset.getByLabel("Y", { exact: true }).fill("0"); await bcOffset.getByLabel("Z", { exact: true }).fill("-40");
const bcZeros = page.locator("fieldset.machine-zero"); await bcZeros.getByLabel("B", { exact: true }).fill("12"); await bcZeros.getByLabel("C", { exact: true }).fill("5"); await control("machine-manager-apply").click();
await page.waitForFunction(() => { const view = document.querySelector(".xyzac-machine-view"); if (view?.dataset.machineManagerSchema !== "linuxcnc.machine-manager/1") return false; try { return JSON.parse(view.dataset.machineManagerGeometry).coordinates === "XYZBC" && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false"; } catch { return false; } }, null, { timeout: 45000 });
const bcGeometry = JSON.parse(await page.locator(".xyzac-machine-view").getAttribute("data-machine-manager-geometry")); assert.deepEqual(bcGeometry.cOriginMm, [-18, 14, 12]); assert.deepEqual(bcGeometry.tiltOffsetMm, [22, 0, -40]); assert.equal(bcGeometry.physicalZero.b, 12);
report.checks.push("same component assembly switches to BC hierarchy and applies BC geometry");
await page.keyboard.press("Escape"); await control("toolbar-power").click(); await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisStateMachine === "ON"); await control("manual-home-all").click();
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisHomed === "[true,true,true,true,true]", null, { timeout: 45000 }); await control("toolbar-run").click();
await page.waitForFunction(() => { const main = document.querySelector("main.axis-app"); const joints = JSON.parse(document.querySelector(".xyzac-machine-view")?.dataset.xyzacJoints || "[0,0,0,0,0]"); return Number(main?.dataset.axisCurrentLine || 0) > 0 && joints.some((value) => Math.abs(value) > 1e-6); }, null, { timeout: 45000 });
const runningJoints = JSON.parse(await page.locator(".xyzac-machine-view").getAttribute("data-xyzac-joints")); assert.equal(runningJoints.length, 5); await page.locator(".axis-preview-machine").screenshot({ path: new URL("managed-bc-running.png", output).pathname }); await control("toolbar-stop").click();
report.checks.push("BC five-axis program drives managed model from authoritative WASM joints");
if (!production) {
const projection = await page.evaluate(async () => {
const manager = await import("/src/machine-manager.ts"); const importedMachine = await import("/src/imported-machine.ts");
const sources = await Promise.all(Array.from({ length: 8 }, async (_, index) => manager.loadMachinePart(await (await fetch("/machines/demo-ac-xyz-head.glb")).arrayBuffer(), `source-${index}.glb`)));
const roles = manager.MACHINE_PART_ROLES; const drafts = sources.map((source, index) => { const role = roles[index]; const parentRole = manager.expectedParentRole(role); return { ...source, role, parentId: parentRole ? sources[roles.indexOf(parentRole)].id : null, visible: true, showOrigin: true, originMm: [0, 0, 0], translationMm: [0, 0, 0], rotationDeg: [0, 0, 0] }; });
const run = async (coordinates, cOriginMm, tiltOffsetMm, physicalZero, joints) => {
const asset = await manager.buildManagedMachine({ coordinates, parts: drafts, cOriginMm, tiltOffsetMm, physicalZero }); const built = importedMachine.buildImportedMachine(asset);
built.update({ joints, offsets: { x_rot_point: cOriginMm[0], y_rot_point: cOriginMm[1], z_rot_point: cOriginMm[2], x_offset: tiltOffsetMm[0], y_offset: tiltOffsetMm[1], z_offset: tiltOffsetMm[2], tool_offset: 0 } });
const link = (name) => { let found = null; built.machine.traverse((node) => { if (!found && node.userData.link === name) found = node; }); return found; };
const result = { xMetres: link("x").position.x, tiltQuaternion: link("tilt").quaternion.toArray(), cQuaternion: link("c").quaternion.toArray(), baseData: link("base").userData.linuxcncMachineGeometry };
built.dispose(); importedMachine.disposeMachineScene(asset.scene); return result;
};
let invalid = ""; try { manager.validateMachineAssembly("XYZAC", drafts.map((part, index) => index === 1 ? { ...part, parentId: null } : part)); } catch (error) { invalid = error.message; }
const ac = await run("XYZAC", [24, -12, 8], [0, 18, -45], { x: 5, y: 0, z: 0, a: 10, b: 0, c: 20 }, [15, 0, 0, 30, 50]);
const bc = await run("XYZBC", [-18, 14, 12], [22, 0, -40], { x: 2, y: 0, z: 0, a: 0, b: 12, c: 5 }, [7, 0, 0, 32, 35]);
sources.forEach((source) => importedMachine.disposeMachineScene(source.scene)); return { ac, bc, invalid };
});
const angle = (quaternion) => 2 * Math.acos(Math.min(1, Math.abs(quaternion[3])));
assert.ok(Math.abs(projection.ac.xMetres - .01) < 1e-9); assert.ok(Math.abs(angle(projection.ac.tiltQuaternion) - 20 * Math.PI / 180) < 1e-9); assert.ok(Math.abs(angle(projection.ac.cQuaternion) - 30 * Math.PI / 180) < 1e-9); assert.deepEqual(projection.ac.baseData.cOriginMm, [24, -12, 8]);
assert.ok(Math.abs(projection.bc.xMetres - .005) < 1e-9); assert.ok(Math.abs(angle(projection.bc.tiltQuaternion) - 20 * Math.PI / 180) < 1e-9); assert.ok(Math.abs(angle(projection.bc.cQuaternion) - 30 * Math.PI / 180) < 1e-9); assert.deepEqual(projection.bc.baseData.tiltOffsetMm, [22, 0, -40]); assert.match(projection.invalid, /必须挂到/);
report.checks.push("AC/BC WASM joint projection subtracts physical zero, preserves userData and rejects invalid hierarchy");
} else report.checks.push("production bundle exercised through AC/BC UI apply and WASM program motion");
assert.deepEqual(errors, []); report.status = "verified";
} catch (error) { report.status = "failed"; report.error = error instanceof Error ? error.stack : String(error); await page.screenshot({ path: "/tmp/machine-manager-failure.png", fullPage: true }); throw error; }
finally { report.browser_errors = errors; report.final = await main.evaluate((element) => ({ program: element.dataset.axisProgramStatus, machine: element.dataset.axisStateMachine, path: element.dataset.axisProgramPath })).catch(() => null); await writeFile(new URL(production ? "browser-production.report.json" : "browser.report.json", output), JSON.stringify(report, null, 2) + "\n"); await browser.close(); }
console.log(JSON.stringify(report));

View File

@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
const modulePath = process.env.PLAYWRIGHT_MODULE;
if (!modulePath) {
console.log(JSON.stringify({ schema: "rtcp-program-library-browser.v1", status: "blocked", code: "playwright_unavailable" }));
process.exit(0);
}
const imported = await import(pathToFileURL(modulePath).href);
const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const errors = [];
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`));
page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); });
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const main = page.locator("main.axis-app");
const waitReady = () => page.waitForFunction(() => {
const element = document.querySelector("main.axis-app");
return element?.dataset.axisStateSource === "linuxcnc-wasm" && element?.dataset.axisProgramLoading === "false";
}, null, { timeout: 45000 });
const waitPath = (path) => page.waitForFunction((expected) => {
const element = document.querySelector("main.axis-app");
return element?.dataset.axisProgramLoading === "false" && element?.dataset.axisProgramPath === expected && element?.dataset.axisProgramStatus === "loaded";
}, path, { timeout: 45000 });
const report = { schema: "rtcp-program-library-browser.v1", status: "running", checks: [] };
try {
await page.goto(process.env.RTCP_LIBRARY_URL ?? "http://127.0.0.1:5174/", { waitUntil: "domcontentloaded", timeout: 30000 });
await waitReady();
assert.match(await control("workspace-rtcp-programs").textContent(), /AC(XYZAC) RTCP 程序 \(15\)/);
await control("workspace-rtcp-programs").click();
assert.equal(await control("rtcp-program-scope").inputValue(), "machine");
assert.equal(await control("rtcp-program-select").locator("option").count(), 15);
assert.deepEqual(await control("rtcp-program-select").locator("optgroup").evaluateAll((nodes) => nodes.map((node) => ({ label: node.label, count: node.querySelectorAll("option").length }))), [
{ label: "TRT 旋转倾斜台 XYZAC/XYZBC (12)", count: 12 },
{ label: "gmoccapy TRT 示例 (3)", count: 3 },
]);
report.checks.push("AC machine defaults to 15 XYZAC/common programs");
await control("rtcp-program-scope").selectOption("all");
assert.equal(await control("rtcp-program-select").locator("option").count(), 32);
assert.deepEqual(await control("rtcp-program-select").locator("optgroup").evaluateAll((nodes) => nodes.map((node) => ({ label: node.label, count: node.querySelectorAll("option").length }))), [
{ label: "TRT 旋转倾斜台 XYZAC/XYZBC (16)", count: 16 },
{ label: "gmoccapy TRT 示例 (5)", count: 5 },
{ label: "XYZAB 双旋转台 (3)", count: 3 },
{ label: "Bridge Mill / 5axiskins (4)", count: 4 },
{ label: "Hexapod genhexkins RTCP (3)", count: 3 },
{ label: "独立五轴动态刀长示例 (1)", count: 1 },
]);
report.checks.push("all-source scope retains 32 programs and 6 source groups");
await control("rtcp-program-select").selectOption("trt-remap-m428");
assert.equal(await control("rtcp-program-load").isDisabled(), true);
await control("rtcp-program-source").click();
await page.waitForFunction(() => document.querySelector(".rtcp-program-source")?.value.includes("motion.switchkins-type"));
report.checks.push("support remap is source-readable and execution-disabled");
await control("rtcp-program-select").selectOption("xyzab-tdr-demo");
assert.equal(await control("rtcp-program-load").isDisabled(), true);
assert.match(await page.locator(".rtcp-incompatible").textContent(), /需要 XYZAB/);
report.checks.push("mismatched XYZAB program is visible and execution-disabled");
const xyzacPath = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_3.ngc";
await control("rtcp-program-select").selectOption("trt-xyzac-switchkins-test-3");
assert.equal(await control("rtcp-program-load").isDisabled(), false);
await control("rtcp-program-load").click();
await waitPath(xyzacPath);
assert.match(await main.getAttribute("data-axis-program-source-hash"), /^fnv1a64:/);
assert.ok(Number(await page.locator(".xyzac-tool-preview").getAttribute("data-xyzac-toolpath-count")) > 0);
report.checks.push("XYZAC switchkins test loaded through WASM with canonical source hash and preview");
await page.keyboard.press("Escape");
const xyzbcCase = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini";
await control("workspace-case-select").selectOption(xyzbcCase);
await page.waitForFunction((expected) => document.querySelector('[data-axis-control="workspace-case-select"]')?.value === expected && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", xyzbcCase, { timeout: 45000 });
assert.match(await control("workspace-rtcp-programs").textContent(), /BC(XYZBC) RTCP 程序 \(9\)/);
await control("workspace-rtcp-programs").click();
assert.equal(await control("rtcp-program-scope").inputValue(), "machine");
assert.equal(await control("rtcp-program-select").locator("option").count(), 9);
const xyzbcPath = "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzbc.ngc";
await control("rtcp-program-select").selectOption("gmoccapy-boat-xyzbc");
assert.equal(await control("rtcp-program-load").isDisabled(), false);
await control("rtcp-program-load").click();
await waitPath(xyzbcPath);
assert.ok(Number(await page.locator(".xyzac-tool-preview").getAttribute("data-xyzac-toolpath-count")) > 0);
report.checks.push("BC machine defaults to 9 XYZBC/common programs and loads gmoccapy XYZBC through WASM");
assert.deepEqual(errors, []);
report.status = "verified";
} catch (error) {
report.status = "failed";
report.error = error instanceof Error ? error.stack : String(error);
await page.screenshot({ path: "/tmp/rtcp-program-library-failure.png", fullPage: true });
throw error;
} finally {
report.browser_errors = errors;
report.final = await main.evaluate((element) => ({ coordinates: element.querySelector('[data-axis-control="workspace-case-select"]')?.value, program_path: element.dataset.axisProgramPath, program_status: element.dataset.axisProgramStatus, source_hash: element.dataset.axisProgramSourceHash })).catch(() => null);
await browser.close();
}
console.log(JSON.stringify(report));

View File

@@ -3,7 +3,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const output = new URL("../coverage/trt-parametric/", import.meta.url);
const output = process.env.TRT_EVIDENCE_DIR ? pathToFileURL(`${process.env.TRT_EVIDENCE_DIR.replace(/\/$/, "")}/`) : new URL("../coverage/trt-parametric/", import.meta.url);
await mkdir(output, { recursive: true });
const browser = await chromium.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
@@ -13,6 +13,12 @@ page.on("pageerror", (error) => errors.push(error.message));
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const machine = page.locator(".xyzac-machine-view");
const panel = page.locator(".trt-geometry");
const openGeometry = async () => {
if (!(await page.locator(".geometry-dialog[open]").count())) await control("geometry-settings").click();
};
const closeGeometry = async () => {
if (await page.locator(".geometry-dialog[open]").count()) await page.locator(".geometry-dialog [aria-label=\"关闭机床几何参数\"]").click();
};
const settled = async () => {
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramLoading === "false" && document.querySelector(".trt-geometry")?.getAttribute("aria-busy") === "false");
await page.waitForTimeout(350);
@@ -30,6 +36,7 @@ try {
for (const [id, unsupported] of [["xyzac-trt", "x-offset"], ["xyzbc-trt", "y-offset"]]) {
if (id === "xyzbc-trt") { await control("machine-type-select").selectOption({ label: "XYZBC TRT" }); await settled(); }
assert.equal(await machine.getAttribute("data-xyzac-model-id"), id);
await openGeometry();
assert.equal(await panel.getByLabel(unsupported, { exact: true }).isDisabled(), true);
const beforePath = await page.locator("main").getAttribute("data-axis-program-path");
const original = JSON.parse(await machine.getAttribute("data-xyzac-offsets"));
@@ -38,11 +45,13 @@ try {
await panel.getByLabel("y-rot-point", { exact: true }).fill("-12");
await panel.getByLabel("z-rot-point", { exact: true }).fill("8");
await control("geometry-apply").click(); await settled();
await closeGeometry();
const offsets = JSON.parse(await machine.getAttribute("data-xyzac-offsets"));
assert.equal(offsets.x_rot_point, 24); assert.equal(offsets.y_rot_point, -12); assert.equal(offsets.z_rot_point, 8);
assert.equal(await page.locator("main").getAttribute("data-axis-program-path"), beforePath);
assert.notEqual((await pixels()).hash, before.hash);
await machine.screenshot({ path: new URL(`${id}-model.png`, output).pathname });
await openGeometry();
const downloading = page.waitForEvent("download"); await control("geometry-save").click();
const download = await downloading;
assert.equal(download.suggestedFilename(), `${id}.ini`);
@@ -50,11 +59,13 @@ try {
assert.match(exported, /x-rot-point\s+24/); assert.match(exported, /LIB:basic_sim.tcl/);
assert.doesNotMatch(exported, /HALFILE = .*_cmds.hal/);
await control("geometry-reload").click(); await settled();
await openGeometry();
assert.deepEqual(JSON.parse(await machine.getAttribute("data-xyzac-offsets")), original);
await panel.locator('input[type="file"]').setInputFiles({ name: `${id}.ini`, mimeType: "text/plain", buffer: Buffer.from(exported) });
await page.waitForFunction(() => document.querySelector('.trt-geometry input[aria-label="x-rot-point"]')?.value === "24");
await control("geometry-apply").click(); await settled();
assert.equal(JSON.parse(await machine.getAttribute("data-xyzac-offsets")).x_rot_point, 24);
await closeGeometry();
await control("toolbar-power").click();
await page.waitForFunction(() => document.querySelector('.trt-geometry input[aria-label="x-rot-point"]')?.disabled === true);
assert.equal(await panel.getByLabel("x-rot-point", { exact: true }).isDisabled(), true);
@@ -66,11 +77,13 @@ try {
for (const [width, height] of [[1440, 1000], [390, 844]]) {
await page.setViewportSize({ width, height }); await panel.scrollIntoViewIfNeeded(); await page.waitForTimeout(400);
const canvas = await pixels(); assert.ok(canvas.colors > 100 && canvas.width > 100 && canvas.height >= 220);
const boxes = await panel.locator("label, input:not([hidden]), button").evaluateAll((elements) => elements.map((element) => {
await openGeometry();
const boxes = await page.locator(".geometry-dialog").locator("label, input:not([hidden]), button").evaluateAll((elements) => elements.map((element) => {
const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, width: r.width };
}));
assert.ok(boxes.every((r) => r.width > 0 && r.x >= 0 && r.right <= width), JSON.stringify(boxes));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
await closeGeometry();
const before = await pixels();
await control("machine-rotateLeft").click(); await page.waitForTimeout(400);
assert.notEqual((await pixels()).hash, before.hash);

View File

@@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url";
import { buildXyzacMachine, buildXyzbcMachine } from "../web/react/src/xyzac-machine-model.ts";
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const output = new URL("../coverage/trt-parametric/", import.meta.url);
const output = process.env.TRT_EVIDENCE_DIR ? pathToFileURL(`${process.env.TRT_EVIDENCE_DIR.replace(/\/$/, "")}/`) : new URL("../coverage/trt-parametric/", import.meta.url);
await mkdir(output, { recursive: true });
const browser = await chromium.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });

View File

@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const output = new URL('../coverage/glb-machine/switching/', import.meta.url);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const errors = []; page.on('pageerror', (e) => errors.push(e.message));
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const rendered = () => page.waitForFunction(() => { const e = document.querySelector('.xyzac-machine-view'); return e?.dataset.xyzacTick === e?.dataset.xyzacRenderedTick; });
const sample = () => page.locator('.xyzac-machine-view').evaluate((e) => ({ joints: JSON.parse(e.dataset.xyzacJoints), tip: JSON.parse(e.dataset.xyzacToolTip), tick: Number(e.dataset.xyzacTick) }));
const close = (a, b, label) => a.forEach((v, i) => assert.ok(Math.abs(v - b[i]) < 1e-8, `${label}[${i}] ${v} != ${b[i]}`));
async function mdi(block) {
await control('tab-mdi').click(); await control('mdi-command').fill(block); await control('mdi-submit').click();
await page.waitForFunction(() => document.querySelector('.command-status')?.textContent?.includes('MDI_EXECUTE acknowledged'));
}
const report = { report_id: 'trt-stationary-switch-browser-v1', status: 'running', machines: [] };
try {
await page.goto('http://127.0.0.1:5174/');
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
for (const id of ['ac', 'bc']) {
await control('machine-glb-settings').click(); await control(`machine-glb-${id}`).click();
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
await page.keyboard.press('Escape');
await control('toolbar-power').click(); await control('tab-manual').click(); await control('manual-home-all').click();
await page.waitForFunction(() => !document.querySelector('[data-axis-control="switchkins-1"]').disabled);
const tilt = id[0].toUpperCase();
await mdi(`G21 G90 G0 X0 Y0 Z44.5858 ${tilt}4.8675 C179.88`);
await page.waitForFunction(() => Math.abs(JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[4] - 179.88) < 1e-7);
await control('tab-manual').click();
await page.waitForFunction(() => !document.querySelector('[data-axis-control="switchkins-1"]').disabled);
await rendered();
const initial = await sample(), rows = [];
await page.locator('.axis-preview-machine').screenshot({ path: new URL(`${id}-identity.png`, output).pathname });
for (const type of [1, 0, 1, 0, 2, 1, 0]) {
await control(`switchkins-${type}`).click();
await page.waitForFunction((type) => document.querySelector(`[data-axis-control="switchkins-${type}"]`)?.getAttribute('aria-pressed') === 'true', type);
await rendered(); const after = await sample(); close(after.joints, initial.joints, `${id} button joints`); close(after.tip, initial.tip, `${id} button tip`);
await page.waitForTimeout(100); const idle = await sample(); close(idle.tip, initial.tip, `${id} idle tip`);
rows.push({ type, after });
if (type === 1) await page.locator('.axis-preview-machine').screenshot({ path: new URL(`${id}-tcp.png`, output).pathname });
}
for (const type of [1, 0, 1]) {
await mdi(type === 1 ? 'M428' : 'M429'); await rendered();
const after = await sample(); close(after.joints, initial.joints, `${id} MDI joints`); close(after.tip, initial.tip, `${id} MDI tip`);
rows.push({ mdi: true, type, after });
}
// A subsequent rotary jog must still produce actual XYZ compensation.
await control('tab-manual').click(); await control(`manual-axis-${id[0]}`).check();
const beforeJog = await sample(), box = await control('manual-jog-positive').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down();
try { await page.waitForFunction((angle) => JSON.parse(document.querySelector('.xyzac-machine-view').dataset.xyzacJoints)[3] > angle + 1, beforeJog.joints[3]); }
finally { await page.mouse.up(); }
await page.waitForFunction(() => document.querySelector('.command-status')?.textContent?.includes('JOG_STOP acknowledged'));
await rendered(); const afterJog = await sample();
assert.ok(afterJog.joints.slice(0, 3).some((v, i) => Math.abs(v - beforeJog.joints[i]) > 1e-5));
const compensation = await page.evaluate(async ({ id, beforeJog, afterJog }) => {
const api = await import('/src/imported-machine.ts');
const asset = await api.loadImportedMachine(await (await fetch(`/machines/demo-${id}-xyz-head.glb`)).arrayBuffer(), `${id}.glb`);
const model = api.buildImportedMachine(asset), offsets = { ...asset.structure.offsets, tool_offset: 0 };
model.update({ ...beforeJog, offsets });
const target = model.workOrigin.worldToLocal(model.toolTip.getWorldPosition(model.toolTip.position.clone()));
model.update({ ...afterJog, offsets });
const tip = model.toolTip.getWorldPosition(target.clone()), point = model.workOrigin.localToWorld(target.clone());
const error = point.distanceTo(tip); model.dispose(); api.disposeMachineScene(asset.scene); return error;
}, { id, beforeJog, afterJog });
assert.ok(compensation < 1e-8);
await control('switchkins-0').click(); await rendered(); close((await sample()).joints, afterJog.joints, 'switch after jog');
await control('toolbar-power').click();
await page.waitForFunction(() => !document.querySelector('[data-axis-control="machine-glb-import"]').disabled);
report.machines.push({ id, initial, rows, afterJog, tcp_jog_error: compensation });
}
assert.deepEqual(errors, []); report.status = 'verified';
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); await page.screenshot({ path: new URL('failure.png', output).pathname }); throw error; }
finally { report.browser_errors = errors; await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); }
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, switches: report.machines.reduce((n, m) => n + m.rows.length + 1, 0) }));

View File

@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
const validator = (await import(process.env.GLTF_VALIDATOR_MODULE ? pathToFileURL(process.env.GLTF_VALIDATOR_MODULE).href : 'gltf-validator')).default;
const root = new URL('../', import.meta.url), output = process.env.GLB_EVIDENCE_DIR ? pathToFileURL(`${process.env.GLB_EVIDENCE_DIR.replace(/\/$/, '')}/`) : new URL('coverage/glb-machine/', root);
const manifest = JSON.parse(await readFile(new URL('web/react/public/machines/manifest.json', root), 'utf8'));
const report = { report_id: 'glb-machine-khronos-validation-v1', validator: validator.version(), status: 'verified', reports: [] };
for (const machine of manifest.machines) {
const bytes = await readFile(new URL(`web/react/public/machines/${machine.filename}`, root));
const sha256 = createHash('sha256').update(bytes).digest('hex'); assert.equal(sha256, machine.sha256);
const result = await validator.validateBytes(new Uint8Array(bytes), { uri: machine.filename });
report.reports.push({ id: machine.id, sha256, ...result });
if (result.issues.numErrors || result.issues.numWarnings) report.status = 'failed';
}
await writeFile(new URL('gltf-validation.report.json', output), JSON.stringify(report, null, 2) + '\n');
assert.equal(report.status, 'verified');
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, reports: report.reports.map(({ id, issues }) => ({ id, errors: issues.numErrors, warnings: issues.numWarnings })) }));

View File

@@ -0,0 +1,41 @@
"""Verify A4 page count, CJK text, embedded glyphs, bookmarks and source evidence."""
import hashlib
import json
import unicodedata
from pathlib import Path
from pypdf import PdfReader
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT/'docs/teaching/rtcp-beginners'
pdf=OUT/'五轴RTCP与工件坐标-初学者完整讲义.pdf'
r=PdfReader(pdf)
outline=json.loads((OUT/'outline.json').read_text())
layout=json.loads((OUT/'verification/layout.json').read_text())
numeric=json.loads((OUT/'gcode-evidence.json').read_text())
assert len(r.pages)==len(outline)==72
assert numeric['status']=='verified' and numeric['executed_program']['status']=='completed'
assert not layout['errors'] and all(not p['violations'] and not p['overflow'] for p in layout['pages'])
pages=[];fonts={}
normalize=lambda s:''.join(unicodedata.normalize('NFKC',s).split())
for i,page in enumerate(r.pages):
text=page.extract_text();assert len(text)>120,(i,len(text))
assert normalize(outline[i]['title']) in normalize(text),(i,outline[i]['title'])
assert abs(float(page.mediabox.width)-595.28)<1 and abs(float(page.mediabox.height)-841.89)<1
assert '\ufffd' not in text
for entry in page['/Resources'].get('/Font',{}).values():
font=entry.get_object()
for item in font.get('/DescendantFonts',[font]):
value=item.get_object();desc=value.get('/FontDescriptor')
if desc:
desc=desc.get_object();name=str(desc.get('/FontName'))
embedded=bool(value.get('/Subtype')=='/Type3' and value.get('/CharProcs')) or any(k in desc for k in ['/FontFile','/FontFile2','/FontFile3'])
fonts[name]=embedded
pages.append({'page':i+1,'characters':len(text),'title':outline[i]['title']})
assert fonts and all(fonts.values()) and any('CJK' in k for k in fonts)
def count_bookmarks(items):
return sum(count_bookmarks(item) if isinstance(item,list) else 1 for item in items)
bookmarks=count_bookmarks(r.outline)
assert bookmarks>=72
report={'report_id':'rtcp-beginners-pdf-v1','status':'verified','page_count':len(r.pages),'pdf':pdf.name,'bytes':pdf.stat().st_size,'sha256':hashlib.sha256(pdf.read_bytes()).hexdigest(),'pages':pages,'embedded_fonts':fonts,'bookmarks':bookmarks,'numeric_report':numeric['report_id'],'executed_program_status':numeric['executed_program']['status'],'wasm_sha256':numeric['wasm_sha256'],'evidence':[{'path':str(p.relative_to(OUT)),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()} for p in [OUT/'handbook.html',OUT/'outline.json',OUT/'gcode-evidence.json',OUT/'verification/layout.json',*sorted((OUT/'assets').iterdir()),*sorted((OUT/'verification').glob('pdf-contact-*.png'))]]}
(OUT/'verification/acceptance.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n')
print(json.dumps({k:report[k] for k in ['report_id','status','page_count','bytes','bookmarks']},ensure_ascii=False))

View File

@@ -0,0 +1,54 @@
"""Verify the standalone PDF, embedded fonts, text, page dimensions and evidence."""
import hashlib
import json
import unicodedata
from pathlib import Path
from pypdf import PdfReader
root = Path(__file__).resolve().parents[1]
output = root / 'docs/teaching/rtcp-five-axis'
pdf = output / '五轴机床与RTCP-教学课件.pdf'
reader = PdfReader(pdf)
outline = json.loads((output / 'outline.json').read_text())
layout = json.loads((output / 'verification/layout.json').read_text())
numerics = json.loads((output / 'numeric-evidence.json').read_text())
assert len(reader.pages) == len(outline) == 31
assert numerics['status'] == 'verified' and not layout['errors']
assert all(not p['clipped'] and not p['content_overflow'] for p in layout['pages'])
pages = []
fonts = {}
for index, page in enumerate(reader.pages):
text = unicodedata.normalize('NFKC', page.extract_text())
assert len(text) > 100, (index, len(text))
assert abs(float(page.mediabox.width) / float(page.mediabox.height) - 16/9) < .001
assert str(index+1).zfill(2) in text
assert '\ufffd' not in text
for font in page['/Resources'].get('/Font', {}).values():
value = font.get_object()
descendants = value.get('/DescendantFonts', [value])
for item in descendants:
descriptor = item.get_object().get('/FontDescriptor')
if descriptor:
descriptor = descriptor.get_object()
# Chrome embeds CFF CJK glyphs as Type 3 CharProcs instead of
# a FontFile stream; both forms are self-contained PDF fonts.
type3 = item.get_object().get('/Subtype') == '/Type3' and bool(item.get_object().get('/CharProcs'))
embedded = type3 or any(key in descriptor for key in ['/FontFile', '/FontFile2', '/FontFile3'])
name = str(descriptor.get('/FontName'))
fonts[name] = embedded
pages.append({'page': index+1, 'characters': len(text), 'width_pt': float(page.mediabox.width), 'height_pt': float(page.mediabox.height)})
assert fonts and all(fonts.values()), fonts
assert any('CJK' in font for font in fonts), fonts
assert len(reader.outline) >= 31
report = {
'report_id': 'rtcp-teaching-pdf-v1', 'status': 'verified', 'pages': pages,
'pdf': pdf.name, 'bytes': pdf.stat().st_size,
'sha256': hashlib.sha256(pdf.read_bytes()).hexdigest(),
'bookmarks': len(reader.outline), 'embedded_fonts': fonts,
'numeric_report_id': numerics['report_id'], 'wasm_sha256': numerics['wasm_sha256'],
'layout_report': 'verification/layout.json',
'evidence': [{'path': str(p.relative_to(output)), 'sha256': hashlib.sha256(p.read_bytes()).hexdigest()}
for p in [output/'course.html', output/'numeric-evidence.json', output/'outline.json', output/'verification/layout.json', *sorted((output/'assets').glob('*.png'))]],
}
(output / 'verification/acceptance.json').write_text(json.dumps(report, ensure_ascii=False, indent=2)+'\n')
print(json.dumps({'report_id':report['report_id'], 'status':report['status'], 'pages':len(pages), 'bytes':report['bytes'], 'fonts':len(fonts), 'bookmarks':report['bookmarks']},ensure_ascii=False))