MCP server: modelcraft-cli mcp (headless or bridged), read/write roots, end-to-end analysis over MCP (#48)

* io: read and write roots that confine an automation client's file access

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: every file a command reads or writes is checked against the session's roots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: headless pictures of a session (model, geometry, deformed shape and contours)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* mcp: MCP server over stdio with headless and connected backends, roots and caps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* cli: modelcraft-cli mcp [--connect ADDR | --headless] with automation read and write roots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* mcp: end-to-end analysis of a corpus part over MCP (open, tet mesh, define, solve, probe, render)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: MCP server (docs/mcp.md), Claude Code setup, roots; architecture, README, roadmap, attribution

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 22:18:08 -04:00
1 parent e900f41ce3
commit 0899587127
29 files changed
+2405 -49

No files matched your search

+557
View File
@@ -0,0 +1,557 @@
//! The tools: their definitions (JSON schemas) and what they run. Every tool is a thin wrapper
//! over engine commands; the same list serves the headless and the connected backend.
use std::path::Path;
use modelcraft_io::roots::Access;
use serde_json::{Map, Value, json};
use crate::backend::{Backend, PictureRequest};
/// Most commands in one `batch` call.
pub const MAX_STEPS: usize = 10_000;
/// The meshers the `mesh` tool runs, by method; a method whose command this build lacks is
/// left out.
const MESHERS: &[(&str, &str, &str)] = &[
("tet", "mesh.tet.generate", "constrained Delaunay tets of a body {body, tolerance} or an imported surface {surface}"),
("hex", "mesh.hex.generate", "all-hex mesh of a body"),
("hex_sweep", "mesh.hex.sweep", "swept all-hex mesh"),
("hex_mapped", "mesh.hex.mapped", "mapped all-hex mesh"),
("block", "mesh.block", "structured hex block"),
("planar", "mesh.planar", "2D elements of a polygon or a planar face"),
];
/// The analyses the `solve` tool runs; one whose command this build lacks is left out.
const ANALYSES: &[(&str, &str)] =
&[("static", "solve.static"), ("frequency", "solve.frequency"), ("explicit", "solve.explicit"), ("fluid", "solve.fluid"), ("heat", "solve.heat")];
const VIEWS: &[&str] = &["iso", "home", "fit", "front", "back", "top", "bottom", "left", "right"];
fn available<T: Copy>(table: &'static [T], command: fn(T) -> &'static str) -> impl Iterator<Item = T> {
table.iter().copied().filter(move |t| modelcraft_engine::find_command(command(*t)).is_some())
}
fn obj(props: Value, required: &[&str]) -> Value {
let mut o = json!({"type": "object", "properties": props, "additionalProperties": false});
if !required.is_empty() {
o["required"] = json!(required);
}
o
}
fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool, destructive: bool) -> Value {
json!({
"name": name,
"title": title,
"description": desc,
"inputSchema": schema,
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": false},
})
}
fn string(desc: &str) -> Value {
json!({"type": "string", "description": desc})
}
fn integer(desc: &str) -> Value {
json!({"type": "integer", "description": desc})
}
/// Every tool, with its JSON schema.
pub fn tool_definitions() -> Vec<Value> {
let methods: Vec<&str> = available(MESHERS, |m| m.1).map(|m| m.0).collect();
let method_docs: Vec<String> = available(MESHERS, |m| m.1).map(|(m, c, d)| format!("{m} ({c}: {d})")).collect();
let analyses: Vec<&str> = available(ANALYSES, |a| a.1).map(|a| a.0).collect();
vec![
tool(
"list_commands",
"List commands",
"Every command with its label and parameter documentation. Ids are dotted and lower-case (geometry.open, \
mesh.tet.generate, model.material.create, model.step.create, solve.static, results.contour). Optional \
case-insensitive substring `filter` over id and label.",
obj(json!({"filter": string("Substring to match, e.g. \"model.load\", \"mesh\", \"results\"")}), &[]),
true,
false,
),
tool(
"execute",
"Execute a command",
"Run one command with JSON parameters, exactly as the UI, scripts and the CLI do; it never opens a dialog. \
Examples: model.units {system: \"mm-t-s-N-MPa\"}; model.set.create {name: \"root\", geometry: [{body: \"Body1\", \
face: \"F1:start\"}]}; model.material.create {name: \"Steel\", elastic: {type: \"isotropic\", young: 210000, \
poisson: 0.3}, density: 7.85e-9}; model.section.create {name: \"solid\", set: \"all\", material: \"Steel\", \
kind: {type: \"solid\"}}; model.boundary_condition.create {name: \"fix\", type: \"fixed\", target: {set: \
\"root\"}}; model.load.create {name: \"push\", type: \"force\", target: {set: \"tip\"}, vector: [0, 0, -100]}; \
model.step.create {name: \"Load\", procedure: {type: \"static\"}, boundary_conditions: [\"fix\"], loads: \
[\"push\"]}. Returns the command's result; a failure leaves the model unchanged and says why.",
obj(
json!({
"command": string("Command id (see list_commands)"),
"params": {"type": "object", "description": "The command's parameters"},
}),
&["command"],
),
false,
false,
),
tool(
"batch",
"Run several commands",
"Run commands in order, stopping at the first error. Each step is {command, params}. The reply has every \
result and, on an error, the failing step (index from 0) and its error; the steps before it stay applied \
(`undo` takes them back one by one).",
obj(
json!({
"commands": {
"type": "array",
"description": "Steps: [{\"command\": id, \"params\": {...}}, ...]",
"items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]},
},
}),
&["commands"],
),
false,
false,
),
tool(
"inspect_model",
"Inspect the model",
"The model: node and element counts, the unit system and the definitions by kind (model.info); the step \
table (which boundary conditions and loads each step has, model.activity); the open design's bodies with \
their measures and persistent face names (geometry.bodies), which sets name as {body, face}.",
obj(json!({}), &[]),
true,
false,
),
tool(
"check_model",
"Check the model",
"The model check (model.check): every error and warning, each naming its item (a set member not on the \
mesh, an element with no section, a missing density for a frequency step, implausible units…). A model \
with errors does not solve or write.",
obj(json!({}), &[]),
true,
false,
),
tool(
"open",
"Open a file",
"Open by extension: a .modelcraft model (file.open: replaces the model; its design and meshes open again), a \
SolidCraft design .solidcraft (geometry.open: its bodies become the geometry) or a face-tagged .3mf mesh \
(geometry.import.mesh: added as a surface).",
obj(json!({"path": string("Path to a .modelcraft, .solidcraft or .3mf file")}), &["path"]),
false,
true,
),
tool(
"save",
"Save the model",
"Save the model as a .modelcraft file (file.save): to `path`, or to the file it came from.",
obj(json!({"path": string("Path to save to (default: the model's file)")}), &[]),
false,
false,
),
tool(
"write_deck",
"Write a solver deck",
"Write the model as a solver input deck (deck.write): Abaqus input (abaqus), Nastran bulk data (nastran) or \
an LS-DYNA keyword deck (lsdyna). Fails naming every model check error and everything the format cannot \
express.",
obj(
json!({
"format": {"type": "string", "enum": ["abaqus", "nastran", "lsdyna"]},
"path": string("Output file"),
"steps": {"type": "array", "items": {"type": "string"}, "description": "Only these steps (a Nastran run is static or frequency steps)"},
}),
&["format", "path"],
),
false,
false,
),
tool(
"mesh",
"Mesh",
&format!(
"Mesh the geometry with the chosen method and parameters (list_commands shows each mesher's options), then \
report the mesh, the model's counts and the mesh quality (mesh.quality: certified validity, volume, metric \
extremes). Methods: {}.",
method_docs.join("; ")
),
obj(
json!({
"method": {"type": "string", "enum": methods},
"params": {"type": "object", "description": "The mesher's parameters, e.g. {\"body\": \"Body1\", \"tolerance\": 0.5}"},
}),
&["method"],
),
false,
false,
),
tool(
"solve",
"Solve a step",
"Solve a step of the model and keep its result: static (linear static), frequency (modes), explicit \
(explicit dynamics), fluid (lattice Boltzmann), heat when available. `step` may be left out when the model \
has one step of that kind; `options` takes the solver's caps and settings (list_commands). The results \
display then shows the step (show_results changes it).",
obj(
json!({
"analysis": {"type": "string", "enum": analyses},
"step": string("Step name"),
"options": {"type": "object", "description": "More solve parameters, e.g. {\"max_dofs\": 1000000} or {\"mass\": \"lumped\"}"},
}),
&["analysis"],
),
false,
false,
),
tool(
"show_results",
"Show results",
"Choose what the results display shows and read it back: the step, the deformed shape (`deformed`, `scale`: \
a factor or \"auto\"), contours of a nodal field (`field`: displacement, displacement_x/y/z, von_mises, \
max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz, or none; `bands` 2–24), a frequency step's mode or an \
explicit step's frame. The reply has the contour's range: its smallest and largest value and their nodes \
(field displacement gives the largest displacement). Only `step`: show that step.",
obj(
json!({
"step": string("Solved step (default: the one shown)"),
"field": string("Contour field, or \"none\""),
"bands": integer("Contour bands, 2 to 24"),
"deformed": {"type": "boolean", "description": "Draw the deformed shape"},
"scale": {"type": ["number", "string"], "description": "Deformation scale factor or \"auto\""},
"mode": integer("Mode of a frequency step, from 1"),
"frame": integer("Field frame of an explicit step, from 0"),
"time": {"type": "number", "description": "Show the explicit step's frame nearest this time"},
}),
&[],
),
false,
false,
),
tool(
"probe",
"Probe results",
"Stored results at nodes (solve.probe): displacement, reaction, stress and von Mises (static), the shape of \
mode `mode` (frequency), displacement and velocity in field frame `frame` (explicit) and, with `elements`, \
explicit element values; for a fluid step, the flow at `points` and the force histories of `forces`.",
obj(
json!({
"step": string("Solved step"),
"nodes": {"type": "array", "items": {"type": "integer"}, "description": "Node labels"},
"elements": {"type": "array", "items": {"type": "integer"}, "description": "Element labels (explicit)"},
"points": {"type": "array", "items": {"type": "array"}, "description": "Points [x, y, z] (fluid)"},
"forces": {"type": "array", "items": {"type": "string"}, "description": "Walls or faces whose force histories to return (fluid)"},
"mode": integer("Mode, from 1 (frequency)"),
"frame": integer("Field frame, from 0 (explicit, fluid)"),
}),
&[],
),
true,
false,
),
tool(
"reactions",
"Reaction sums",
"Reaction force and moment (about `about`) summed over each node set and over the whole model \
(results.reactions); `sets` defaults to the sets the step's boundary conditions hold.",
obj(
json!({
"step": string("Solved static step"),
"sets": {"type": "array", "items": {"type": "string"}, "description": "Node sets"},
"about": {"type": "array", "items": {"type": "number"}, "description": "Moment point [x, y, z] (default origin)"},
}),
&[],
),
true,
false,
),
tool(
"screenshot",
"Look at the model",
"A PNG of the model and the results display. Headless: a CPU render framing everything drawn. Connected to \
the app: the whole window drawn offscreen (source \"window\", the default; ui.capture) or the model with the \
app's camera (source \"model\"; ui.render). `view` turns the camera to a standard view first.",
obj(
json!({
"view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"},
"source": {"type": "string", "enum": ["window", "model"], "description": "window = the app's window, model = a model render"},
"width": integer("Width in pixels (window: points), 16 to 4096"),
"height": integer("Height in pixels (window: points), 16 to 4096"),
"path": string("Also save the PNG here"),
}),
&[],
),
true,
false,
),
tool("undo", "Undo", "Take back the last command that changed the model.", obj(json!({}), &[]), false, false),
tool("redo", "Redo", "Run the last undone change again.", obj(json!({}), &[]), false, false),
]
}
fn type_matches(t: &str, v: &Value) -> bool {
match t {
"string" => v.is_string(),
"number" => v.is_number(),
"integer" => v.is_i64() || v.is_u64(),
"boolean" => v.is_boolean(),
"array" => v.is_array(),
"object" => v.is_object(),
"null" => v.is_null(),
_ => true,
}
}
/// Checks `args` against the tool's schema (top level): unknown tool, missing required
/// arguments, unknown arguments, type and enum mismatches.
pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
let defs = tool_definitions();
let shown: String = name.chars().take(100).collect();
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{shown}`"))?;
let schema = &def["inputSchema"];
let Some(a) = args.as_object() else { return Err(format!("{name}: arguments must be an object")) };
for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
if a.get(r).is_none_or(Value::is_null) {
return Err(format!("{name}: missing required argument `{r}`"));
}
}
let props = schema["properties"].as_object();
for (k, v) in a {
let Some(p) = props.and_then(|p| p.get(k)) else {
return Err(format!("{name}: unknown argument `{}`", k.chars().take(100).collect::<String>()));
};
if v.is_null() {
continue;
}
let ok = match &p["type"] {
Value::String(t) => type_matches(t, v),
Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)),
_ => true,
};
if !ok {
return Err(format!("{name}: argument `{k}` must be of type {}", p["type"]));
}
if let Some(allowed) = p["enum"].as_array()
&& !allowed.contains(v)
{
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
}
}
Ok(())
}
/// A tool result: one text block and the error flag.
fn text_result(v: &Value, is_error: bool) -> Value {
let text = match v {
Value::String(s) => s.clone(),
v => serde_json::to_string_pretty(v).unwrap_or_default(),
};
let mut r = json!({"content": [{"type": "text", "text": text}]});
if is_error {
r["isError"] = json!(true);
}
r
}
/// Run a tool. A failure is a tool result with `isError: true` (the model reads the message and
/// corrects); nothing here panics on hostile arguments.
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Value {
let empty = Map::new();
let a = args.as_object().unwrap_or(&empty);
let r = match name {
"screenshot" => return screenshot(b, a),
"batch" => return batch(b, a),
_ => dispatch(b, name, a),
};
match r {
Ok(v) => text_result(&v, false),
Err(e) => text_result(&Value::String(e), true),
}
}
fn pick(a: &Map<String, Value>, keys: &[&str]) -> Value {
Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect())
}
fn str_arg<'a>(a: &'a Map<String, Value>, k: &str) -> Option<&'a str> {
a.get(k).and_then(Value::as_str)
}
/// An object argument (absent: empty).
fn object_arg(a: &Map<String, Value>, k: &str) -> Value {
a.get(k).cloned().filter(Value::is_object).unwrap_or_else(|| json!({}))
}
fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<Value, String> {
match name {
"list_commands" => {
let all = b.commands()?;
let Some(f) = str_arg(a, "filter").map(str::to_lowercase).filter(|f| !f.is_empty()) else { return Ok(all) };
let hit = |c: &Value| ["id", "label"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f)));
Ok(Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default()))
}
"execute" => {
let id = str_arg(a, "command").ok_or("`command` is required")?;
b.execute(id, object_arg(a, "params"))
}
"inspect_model" => Ok(json!({
"model": b.execute("model.info", json!({}))?,
"steps": b.execute("model.activity", json!({}))?,
"bodies": b.execute("geometry.bodies", json!({}))?,
})),
"check_model" => b.execute("model.check", json!({})),
"open" => {
let path = str_arg(a, "path").ok_or("`path` is required")?;
let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
let command = match ext.as_deref() {
Some("modelcraft") => "file.open",
Some("solidcraft") => "geometry.open",
Some("3mf") => "geometry.import.mesh",
_ => return Err("open: a .modelcraft model, a .solidcraft design or a .3mf mesh".into()),
};
let r = b.execute(command, json!({"path": path}))?;
Ok(json!({"command": command, "result": r}))
}
"save" => b.execute("file.save", pick(a, &["path"])),
"write_deck" => b.execute("deck.write", pick(a, &["format", "path", "steps"])),
"mesh" => {
let method = str_arg(a, "method").ok_or("`method` is required")?;
let (_, command, _) = available(MESHERS, |m| m.1).find(|m| m.0 == method).ok_or_else(|| format!("unknown mesh method `{method}`"))?;
let mesh = b.execute(command, object_arg(a, "params"))?;
let model = b.execute("model.info", json!({}))?;
let quality = b.execute("mesh.quality", json!({})).unwrap_or_else(|e| json!({"error": e}));
Ok(json!({"command": command, "mesh": mesh, "model": model, "quality": quality}))
}
"solve" => {
let analysis = str_arg(a, "analysis").ok_or("`analysis` is required")?;
let (_, command) = available(ANALYSES, |x| x.1).find(|x| x.0 == analysis).ok_or_else(|| format!("unknown analysis `{analysis}`"))?;
let mut params = object_arg(a, "options");
if let Some(step) = a.get("step").filter(|s| !s.is_null()) {
params["step"] = step.clone();
}
b.execute(command, params)
}
"show_results" => show_results(b, a),
"probe" => b.execute("solve.probe", pick(a, &["step", "nodes", "elements", "points", "forces", "mode", "frame"])),
"reactions" => b.execute("results.reactions", pick(a, &["step", "sets", "about"])),
"undo" => b.execute("edit.undo", json!({})),
"redo" => b.execute("edit.redo", json!({})),
other => Err(format!("unknown tool `{}`", other.chars().take(100).collect::<String>())),
}
}
/// `show_results`: the `results.*` commands its arguments ask for, in a fixed order; the reply of
/// the last one (each replies with the whole display).
fn show_results(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<Value, String> {
let step = pick(a, &["step"]);
let with_step = |mut p: Value| {
if let (Some(o), Some(s)) = (p.as_object_mut(), step.get("step")) {
o.insert("step".into(), s.clone());
}
p
};
let mut last = None;
if let Some(mode) = a.get("mode").filter(|v| !v.is_null()) {
last = Some(b.execute("results.modes", with_step(json!({"mode": mode})))?);
}
if a.get("frame").is_some_and(|v| !v.is_null()) || a.get("time").is_some_and(|v| !v.is_null()) {
last = Some(b.execute("results.frame", with_step(pick(a, &["frame", "time"])))?);
}
if a.get("deformed").is_some_and(|v| !v.is_null()) || a.get("scale").is_some_and(|v| !v.is_null()) {
let mut p = pick(a, &["scale"]);
if let Some(show) = a.get("deformed").filter(|v| !v.is_null()) {
p["show"] = show.clone();
}
last = Some(b.execute("results.deformed", with_step(p))?);
}
if a.get("field").is_some_and(|v| !v.is_null()) {
last = Some(b.execute("results.contour", with_step(pick(a, &["field", "bands"])))?);
} else if a.get("bands").is_some_and(|v| !v.is_null()) {
return Err("show_results: `bands` goes with `field`".into());
}
match last {
Some(v) => Ok(v),
// Only a step: show it.
None if step.get("step").is_some() => b.execute("results.deformed", step),
None => Err("show_results: give `step`, `field`, `deformed`, `scale`, `mode`, `frame` or `time`".into()),
}
}
/// `batch`: run steps in order, stopping at the first failure.
fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
let Some(steps) = a.get("commands").and_then(Value::as_array) else {
return text_result(&json!("`commands` must be an array of {command, params}"), true);
};
if steps.len() > MAX_STEPS {
return text_result(&json!(format!("batch too long ({} steps, at most {MAX_STEPS})", steps.len())), true);
}
let mut results = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let command = step.get("command").and_then(Value::as_str);
let r = match command {
Some(id) => b.execute(id, step.get("params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}))),
None => Err("missing `command`".to_string()),
};
match r {
Ok(v) => results.push(v),
Err(e) => {
return text_result(
&json!({"ok": false, "completed": i, "failed": {"index": i, "command": command, "error": e}, "results": results}),
true,
);
}
}
}
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
}
/// `screenshot`: a PNG as an MCP image content block, with what it shows as text.
fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
match capture(b, a) {
Ok((png, info)) => json!({"content": [
{"type": "image", "data": base64(&png), "mimeType": "image/png"},
{"type": "text", "text": info.to_string()},
]}),
Err(e) => text_result(&Value::String(e), true),
}
}
fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Value), String> {
// The file is checked before anything is drawn.
let keep = match str_arg(a, "path").filter(|p| !p.trim().is_empty()) {
Some(p) => Some(b.roots().check(Path::new(p), Access::Write).map_err(|e| e.to_string())?),
None => None,
};
let size = |k: &str| a.get(k).and_then(Value::as_u64).map(|v| u32::try_from(v).unwrap_or(u32::MAX));
let request = PictureRequest {
view: str_arg(a, "view").map(str::to_string),
window: str_arg(a, "source") != Some("model"),
width: size("width"),
height: size("height"),
};
let picture = b.picture(&request)?;
let mut info = picture.info;
if let Some(path) = &keep {
modelcraft_io::fs::write_atomic(path, &picture.png, false).map_err(|e| e.to_string())?;
info["path"] = json!(path.to_string_lossy());
}
info["bytes"] = json!(picture.png.len());
Ok((picture.png, info))
}
/// Standard base64 with padding (RFC 4648).
pub(crate) fn base64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
let digit = T.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'=');
out.push(char::from(if i <= chunk.len() { digit } else { b'=' }));
}
}
out
}