Compare commits

..
3 Commits
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 28c13316ca docs: units inventory, every place a dimensional number enters or leaves
Part of #579.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 10:30:33 -04:00
jyvikoandClaude Opus 5.5 d4dd0b6bf2 xtask: unit-factors report of literal conversion factors outside the unit table
Part of #579. Lists float literals equal to common conversion factors
(1e-3, 1e6, 273.15, 25.4, 9.81, …) outside crates/model/src/units; report
only, so later steps can drive it to zero before it becomes a CI check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 10:30:33 -04:00
jyvikoandClaude Opus 5.5 af7836d3ce model: SI unit table (dimensions, units, quantities, display presets)
Part of #579. A units table in fieldcraft-model: Dimension as SI base
exponents, named units with factors (offsets only for absolute °C/°F),
unit expressions (t/mm³, W/(m·K)), Quantity kinds with parsing of
"210 GPa" / {value, unit} under dimension checks, formatting, and display
presets (SI, m-kg-s, mm-t-s-N-MPa, in-lbf-s-psi). Model storage unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 10:30:33 -04:00
27 changed files with 1566 additions and 2798 deletions

No files matched your search

-48
View File
@@ -1,48 +0,0 @@
# OneCAD Simulation rebrand map (identity layer)
OneCAD Simulation is the OneBSpace fork of FieldCraft (a pure-Rust CAE app: FEA, meshing,
thermal, LBM CFD, with a WASM web app at `apps/fieldcraft-web`). The rebrand is **source-level**
for everything the user can see or click; only the compatibility layer keeps upstream names (so
`.fieldcraft` files, crate names, storage keys and internal identifiers stay upstream-compatible,
and every upstream sync stays a clean merge). Files tagged `// OneBSpace (OneCAD Simulation):`
are downstream-only deltas — one tag per edited site, above or beside the change, so the whole
fork layer can be found with `grep -rn "OneBSpace (OneCAD Simulation)"`.
## Rebranded in source (user-visible)
| Layer | Change |
|---|---|
| Web page | `<title>OneCAD Simulation`, description and social-card meta renamed; upstream's `tangency-labs.github.io/fieldcraft` `og:url`/`og:image`/`twitter:image` dropped; loading, no-JavaScript and could-not-start text say "OneCAD Simulation" (`apps/fieldcraft-web/index.html`) |
| App bar | upstream's drawn fringe-cube vector mark dropped; a painted `OneCAD Simulation` wordmark (`appbar.rs::APP_NAME`) shows in **every** width variant — the compact bar keeps the name and the quick actions start after the measured wordmark (`bar_layout` gained a `name_w` argument) |
| Help menu | Samples, navigation help, Crash Reports…, Settings… and the version label `OneCAD Simulation {VERSION}` remain; upstream's `Report a Bug…` (GitHub draft), `Check for Updates` and `Reinstall FieldCraft…` items and their handler branches are removed (`appbar.rs::help_menu`) |
| Bug/crash reports | fully local: the Report-a-Bug form's `Open GitHub Issue` became a `Copy report` button — the draft (title, fields, session) goes to the clipboard and attachments to the local `bug-reports/` folder; nothing opens a browser, `ISSUE_URL` is never opened (`report.rs::copy_report`); crash questions/headings say "OneCAD Simulation closed unexpectedly / hit an internal error" |
| Crash messages | the engine's unclean-exit and native-crash messages (rendered in the crash dialog and the copied report) name the fork (`engine/src/crash.rs`) |
| Command catalogue | `diagnostics.bug_report` and `app.update.check` palette help texts describe the local draft / name the fork (`engine/src/cmd/diagnostics.rs`, `engine/src/cmd/update.rs`) |
| Updates (desktop-only) | every status line, the notice and the install dialog say "OneCAD Simulation" (`updates.rs`); the web build has no update host, so none of this runs in the browser; "What's new"/release URLs stay desktop-only logic, rebranded text only |
| Privacy | consent question and dialog title, Settings copy and tooltips say "OneCAD Simulation"; the upstream GitHub `PRIVACY_URL` is no longer rendered as a link nor opened — the privacy note is plain text (`privacy.rs`); the constant is kept for reference |
| Navigation | the default mouse-mapping preset's label is "OneCAD Simulation" (its `id: "fieldcraft"` stays, preferences depend on it) |
| Desktop window | window title / `run_native` app name "OneCAD Simulation" (`apps/fieldcraft/src/main.rs`; the `APP_ID` reverse-DNS id stays) |
| Attachments | the bug-report session attachment is `onecadsim-session.json` (`engine/src/bug_report.rs::SESSION_FILE`); the model attachment keeps the `.fieldcraft` name |
## Stays upstream (compatibility & scale)
- **The `.fieldcraft` file-format extension** (and `model.fieldcraft` attachment naming); crate
names and the Cargo workspace layout — untouched, so upstream syncs stay mergeable.
- **localStorage keys** (`fieldcraft-recovery/`, `fieldcraft-prefs`) and the browser recovery
folder layout.
- **HTML element ids** (`fieldcraft_canvas`, `fieldcraft_loading`) and the trunk tags in
`apps/fieldcraft-web/index.html`.
- **Environment variable names** (`FIELDCRAFT_POSTHOG_KEY`, `FIELDCRAFT_TELEMETRY`,
`FIELDCRAFT_CONTROL_PORT`, …). Telemetry logic is untouched: the Odoo build simply does not set
`FIELDCRAFT_POSTHOG_KEY`, so the PostHog sender stays inert; no key or host URL is surfaced in
the UI.
- **Internal identifiers**: egui window/id salts (`fc_appbar`, `fc_report_bug`, …), command ids
(`diagnostics.bug_report`), the `fieldcraft` nav-preset id, `SESSION_FILE`/`ISSUE_URL` API
names, the `fieldcraft-cli` binary name quoted in report text.
- Desktop-only updater internals (release URLs, package layouts) keep upstream strings —
unreachable from the web build.
## Not touched here
`crates/ui-egui/src/files.rs` and `apps/fieldcraft-web/src/{web,main}.rs` plus `odoo/*` are owned
by other agents in this task train; their user-visible strings are handled there.
+22 -29
View File
@@ -3,43 +3,47 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>OneCAD Simulation</title>
<title>FieldCraft</title>
<meta name="description" content="Open-source CAE modeling in Rust: mesh a part, set up the analysis and solve it, in your browser.">
<!-- OneBSpace: upstream's social-card meta (og: / twitter:) and its og.png copy are
stripped — no social junk in this app's bundle. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="FieldCraft">
<meta property="og:title" content="FieldCraft">
<meta property="og:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
<meta property="og:url" content="https://tangency-labs.github.io/fieldcraft/">
<meta property="og:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="FieldCraft: Open-source CAE built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="FieldCraft">
<meta name="twitter:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
<meta name="twitter:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
<link data-trunk rel="rust" data-bin="fieldcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" type="image/svg+xml" href="../../assets/app-icon/icon-small.svg" />
<link data-trunk rel="copy-file" href="../../assets/app-icon/icon.iconset/icon_128x128@2x.png" data-target-path="icons" />
<link data-trunk rel="copy-file" href="../../docs/media/og.png" />
<link rel="apple-touch-icon" href="icons/icon_128x128@2x.png">
<!-- OneBSpace: the Odoo Documents host bridge glue (src/odoo). Copied verbatim
(not hashed): the framework's bundleVersion ?v= on the iframe URL is the
cache buster, like every other OneBSpace app bundle. -->
<link data-trunk rel="copy-dir" href="src/odoo" data-target-path="odoo" />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262c37; }
#fieldcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#fieldcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 16px; padding: 0 16px; text-align: center; color: #c3cad6; font: 14px system-ui, sans-serif; pointer-events: none; }
/* OneBSpace: the upstream logo image is gone — a plain wordmark instead. */
#fieldcraft_loading .wordmark { font: 600 clamp(28px, 6vmin, 52px) system-ui, sans-serif;
letter-spacing: 0.18em; text-indent: 0.18em; color: #c3cad6; user-select: none; }
#fieldcraft_loading img { width: 96px; height: 96px; }
#fieldcraft_loading p { margin: 0; max-width: 32em; }
#fieldcraft_loading.failed { pointer-events: auto; }
#fieldcraft_loading.failed .spinner { display: none; }
#fieldcraft_loading.failed img, #fieldcraft_loading.failed .spinner { display: none; }
.spinner { width: 24px; height: 24px; border-radius: 50%; border: 3px solid #4a5468; border-top-color: #c3cad6; animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
</style>
</head>
<body>
<canvas id="fieldcraft_canvas"></canvas>
<div id="fieldcraft_loading" role="status">
<span class="wordmark">OneCAD Simulation</span>
<div id="fieldcraft_loading">
<img src="icons/icon_128x128@2x.png" alt="">
<div class="spinner"></div>
<span class="sr-only">Loading OneCAD Simulation&hellip;</span>
<!-- // OneBSpace (OneCAD Simulation): the no-JavaScript note names the fork -->
<noscript><p>OneCAD Simulation needs JavaScript and WebAssembly.</p></noscript>
<p>Loading FieldCraft&hellip;</p>
<noscript><p>FieldCraft needs JavaScript and WebAssembly.</p></noscript>
</div>
<script>
// The page could not load or start the app (no WebAssembly, a failed download): say so
@@ -50,8 +54,7 @@
if (!el || el.classList.contains("failed")) return;
el.classList.add("failed");
el.textContent = "";
// // OneBSpace (OneCAD Simulation): the failure message names the fork
["OneCAD Simulation could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
["FieldCraft could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
var p = document.createElement("p");
p.textContent = t;
el.appendChild(p);
@@ -61,15 +64,5 @@
addEventListener("unhandledrejection", function (e) { fail(String(e.reason && e.reason.message || e.reason)); });
})();
</script>
<script>
// OneBSpace: boot the Odoo Documents bridge glue only inside a frame (the
// wasm_app_framework shell). Standalone runs never import it.
// odoo/index.js is src/odoo/index.ts compiled by the addon build script.
try {
if (window.parent !== window) {
import("./odoo/index.js?v=20.0.1.0.0").catch((e) => console.warn("OneCAD Simulation odoo bridge:", e));
}
} catch (e) { /* cross-origin parent (standalone) — skip */ }
</script>
</body>
</html>
-3
View File
@@ -26,9 +26,6 @@ mod storage;
mod usage;
#[cfg(target_arch = "wasm32")]
mod web;
// OneBSpace (OneCAD Simulation): Odoo Documents host integration
#[cfg(target_arch = "wasm32")]
mod services_odoo;
#[cfg(target_arch = "wasm32")]
fn main() {
-947
View File
@@ -1,947 +0,0 @@
// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
// OneCAD Simulation Odoo Documents glue (ported from OneCAD).
/**
* OneCAD Simulation odoo/bridge.ts — the Odoo Documents bridge client + Rust facade.
* -----------------------------------------------------------------------
* Port of the family adapter (one-cut-pro apps/web/src/odoo/bridge.ts,
* vanilla twin: onepixel frontend/src/odoo-host/bridge.ts) onto the
* wasm_app_framework host protocol — authoritative implementation:
* one-os-dev/core-components/wasm_app_framework/static/src/wasm_app_action.js
*
* request : {source: "one-cad-simulation", kind: "odoo-documents", id, method, payload}
* posted on window.parent (same-origin Odoo shell iframe)
* reply : {source: "one-cad-simulation-host", kind: "odoo-documents", id, ok,
* result | error:{message}}
*
* Host methods and payload shapes (confirmed against the framework):
* ping {} -> {user, folder, app:{root, projects?, assets?, exports?}}
* list {} -> {documents:[{id,name,updatedAt,size}]}
* read {recordId} -> {name, dataB64}
* write {recordId?, name?, dataB64, folderId?} -> {id}
* delete {recordId} -> {deleted}
* usage {} -> {bytesUsed, documentCount}
* folders {} -> {tree:[{id,name,parentId,system}], app:{...app, appsFilesRoot}}
* createFolder {name, parentId?} -> {id}
* listFolder {folderId} -> {folders:[{id,name,system}], files:[{id,name,ext,mimetype,size,updatedAt}]}
* saveExport {name, dataB64, folderId?} -> {id} (folderId omitted -> the app's "exports" subfolder)
* stat {recordId} -> {id, name, mimetype}
*
* Family laws: Documents is the ONLY storage; 60 s calls / 300 s writes;
* one correlation id per call; air-gapped (no external fetch ever).
* The 128 MB per-record cap is enforced HERE, on the raw bytes, before any
* base64 conversion (the host mirrors it on base64 length * 3/4).
*
* RUST EVENT CONTRACT (window CustomEvents; the Rust side is built
* separately and consumes host responses through these):
*
* LISTEN "onecadsim:host-request" detail {id:number, method:string,
* (Rust -> JS) payload?:object}
* One door for everything. Known host
* methods (ping/list/read/write/delete/
* usage/folders/createFolder/listFolder/
* saveExport/stat) pass straight through
* to the host with their own payload.
* Orchestration methods run dialogs +
* writes in JS and never hit the host:
* "saveAs" {name?, dataB64?, recordId?, bytes?}
* "open" {}
* "export" {name?, dataB64?, bytes?}
* "importAsset" {name?, dataB64?, bytes?}
* Bytes may arrive as dataB64 (JSON) or a
* Uint8Array in payload.bytes; when absent
* the bridge asks the app to serialize
* itself via window.onecadsim (below).
* REQUEST QUEUE: entries are processed
* strictly FIFO, one at a time, and each
* id is answered exactly once.
*
* EMIT "onecadsim:host-response" detail {id, ok, result?, error?{message}}
* (JS -> Rust) correlate with the request id. Timeouts
* surface as ok:false errors (60 s calls,
* 300 s write/read/saveExport).
*
* EMIT "onecadsim:host-ready" detail {host:boolean, documentId, name}
* (JS -> Rust) fired once by the boot glue (odoo/
* index.ts) after waitForHost() and any
* ?document= deep-link read. Late-booting
* Rust can read the same state sync at
* window.onecadsimHost.{ready,host,documentId,name}.
*
* EMIT "onecadsim:document-opened" detail {documentId, name, dataB64}
* (JS -> Rust) fired after the deep-link record was
* read. The full payload stays available
* for late listeners via
* window.onecadsimHost.consumePendingOpen()
* (returns the detail once, then null).
*
* JS -> APP window.onecadsim.request("odoo.document.bytes")
* -> Promise<Uint8Array | ArrayBuffer | {dataB64}> — serialize
* the current workspace (used when a save/export carries no
* bytes). request("odoo.document.info") -> Promise<{name?}> —
* suggested document name; failures are ignored.
*
* STANDALONE DEV MODE — when the app is not inside the framework iframe,
* the facade goes inert: waitForHost() resolves immediately (host:false),
* every host call rejects with a clear "standalone" error and host-request
* events are answered ok:false — the app still boots, nothing hangs.
*
* This file is erasable-syntax-only TypeScript with ZERO dependencies: the
* addon build compiles it to bridge.js next
* to this file (node stripTypeScriptTypes; tsc when available).
*/
const DEFAULT_TIMEOUT_MS = 60_000;
const WRITE_TIMEOUT_MS = 300_000; // large .fieldcraft containers over the bridge
const POLL_INTERVAL_MS = 100;
const HOST_WAIT_MS = 20_000; // ping every 100 ms for up to 20 s
/** Family cap (wasm_app_action.js MAX_DOCUMENT_BYTES), mirrored client-side. */
export const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
export const SOURCE_TAG = "one-cad-simulation";
export const HOST_SOURCE_TAG = "one-cad-simulation-host";
export const BRIDGE_KIND = "odoo-documents";
/** Window CustomEvent names of the Rust facade contract (see header). */
export const EVT_HOST_REQUEST = "onecadsim:host-request";
export const EVT_HOST_RESPONSE = "onecadsim:host-response";
export const EVT_HOST_READY = "onecadsim:host-ready";
export const EVT_DOCUMENT_OPENED = "onecadsim:document-opened";
/** window.onecadsim request names the bridge calls INTO the app. */
export const APP_SERIALIZE_METHOD = "odoo.document.bytes";
export const APP_INFO_METHOD = "odoo.document.info";
export interface OdooAppTree {
root: number;
projects?: number;
assets?: number;
exports?: number;
appsFilesRoot?: number;
}
export interface PingResult {
user: string;
folder: number;
app: OdooAppTree;
}
export interface FolderRow {
id: number;
name: string;
parentId: number | null;
system?: boolean;
}
export interface FileRow {
id: number;
name: string;
ext?: string;
mimetype?: string;
size: number;
updatedAt?: string;
}
export interface WriteResult {
id: number;
name?: string;
}
export interface ReadResult {
name: string;
dataB64: string;
}
export interface StatResult {
id: number;
name: string;
mimetype: string;
}
export interface UsageResult {
bytesUsed: number;
documentCount: number;
}
export interface FoldersResult {
tree: FolderRow[];
app: OdooAppTree;
}
export interface ListResult {
documents: FileRow[];
}
export interface CallOptions {
timeoutMs?: number;
}
export interface BindEntry {
recordId: number;
name: string;
updatedAt: string;
}
/** The document the app is currently working on (deep link / last save). */
export interface CurrentBinding {
documentId: number | null;
name: string | null;
}
export interface ExplorerChoice {
name?: string;
folderId?: number;
recordId?: number;
recordName?: string;
file?: File;
}
export type ExplorerOpener = (opts: {
mode: "save" | "open" | "asset" | "export";
defaultName?: string;
extensions?: string[];
}) => Promise<ExplorerChoice | null>;
export interface HostRequestDetail {
id: number;
method: string;
payload?: Record<string, unknown> | null;
/** services_odoo.rs names the request object `params`; payload is the glue
* spelling. runRequest accepts either. */
params?: Record<string, unknown> | null;
}
export interface HostResponseDetail {
id: number;
ok: boolean;
result?: unknown;
error?: { message: string } | null;
}
export interface HostReadyDetail {
host: boolean;
documentId: number | null;
name: string | null;
}
export interface DocumentOpenedDetail {
documentId: number;
name: string;
dataB64: string;
}
export interface SaveAsResult {
id: number;
name: string;
folderId: number | null;
}
export interface OpenResult {
id: number;
name: string;
dataB64: string;
}
export interface SavedResult {
id: number;
name: string;
}
/** Host method names accepted on "onecadsim:host-request" (pass-through). */
const HOST_METHODS = new Set([
"ping", "list", "read", "write", "delete", "usage",
"folders", "createFolder", "listFolder", "saveExport", "stat",
]);
/** Host methods that move bulk bytes — family law says 300 s. */
const BULK_METHODS = new Set(["read", "write", "saveExport"]);
/** Orchestration methods served by this bridge (dialogs + writes). */
const UI_METHODS = new Set(["saveAs", "open", "export", "importAsset"]);
const BIND_KEY_PREFIX = "one-cad-sim:fieldcraft:bind:";
const OWNED_EXTENSION = ".fieldcraft";
/** Rust services_odoo.rs ASSET_EXTS — pick_program offers these from Documents. */
const RUST_ASSET_EXTS = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
/** Emulated pending paths must sanitize exactly like services_odoo.rs::sanitize. */
function sanitizeName(name: unknown): string {
const cleaned = String(name || "").replace(/[/\\]/g, "_").trim();
return cleaned || "file";
}
/** Pull the Uint8Array payload out of an odoo.write / odoo.import event. */
function bytesOf(payload: Record<string, unknown>): Uint8Array | null {
const raw = payload ? payload.bytes : undefined;
if (!raw) return null;
if (raw instanceof Uint8Array) return raw;
// Cross-realm payloads (tests, odd hosts): isView works across realms.
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(raw)) {
return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
}
if (typeof ArrayBuffer !== "undefined" && raw instanceof ArrayBuffer) return new Uint8Array(raw);
return normalizeBytes(raw);
}
function newToken(): string {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return "t" + window.crypto.randomUUID().replace(/-/g, "");
}
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2);
}
interface PendingTokenBinding {
recordId: number | null;
name: string;
folderId: number | null;
}
interface OnecadsimApi {
request?: (method: string, params?: unknown) => Promise<unknown>;
}
interface PendingEntry {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
interface QueueEntry {
id: number;
method: string;
payload: Record<string, unknown>;
}
/* ------------------------------------------------------------------ */
/* Codec + guard helpers (exported for the explorer and the app glue) */
/* ------------------------------------------------------------------ */
/** Chunked btoa so multi-megabyte CAD containers never blow the arg stack. */
export function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.subarray(i, i + chunk);
binary += String.fromCharCode.apply(null, Array.from(slice) as unknown as number[]);
}
return btoa(binary);
}
export function base64ToBytes(dataB64: string): Uint8Array {
const binary = atob(dataB64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/** Family 128 MB cap, enforced on RAW bytes before base64 conversion. */
export function guardCap(bytes: Uint8Array): void {
if (bytes.byteLength > MAX_DOCUMENT_BYTES) {
throw new Error(
"OneCAD Simulation odoo bridge: document exceeds the 128 MB bridge limit (" +
bytes.byteLength + " raw bytes)",
);
}
}
function normalizeBytes(value: unknown): Uint8Array | null {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (value && typeof value === "object") {
const holder = value as { dataB64?: unknown };
if (typeof holder.dataB64 === "string") return base64ToBytes(holder.dataB64);
}
return null;
}
function ensureExtension(name: string, extension: string): string {
const trimmed = (name || "").trim() || "Untitled";
const dot = trimmed.lastIndexOf(".");
if (dot > 0) return trimmed;
return trimmed + extension;
}
function stripOwnedExtension(name: string): string {
return (name || "").replace(/\.(fieldcraft|solidcraft)$/i, "");
}
function detectInFrame(): boolean {
try {
return window.parent !== window;
} catch {
return false;
}
}
function onecadsimApi(): OnecadsimApi | null {
const holder = window as unknown as { onecadsim?: OnecadsimApi };
const api = holder.onecadsim;
return api && typeof api.request === "function" ? api : null;
}
function messageOf(error: unknown): string {
return error && typeof error === "object" && "message" in error
? String((error as { message?: unknown }).message)
: String(error);
}
/* ------------------------------------------------------------------ */
/* The bridge */
/* ------------------------------------------------------------------ */
class OneCadBridge {
inFrame: boolean;
standalone: boolean = false;
nextId: number = 1;
pending: Map<number, PendingEntry> = new Map();
queue: QueueEntry[] = [];
draining: boolean = false;
opener: ExplorerOpener | null = null;
binding: CurrentBinding = { documentId: null, name: null };
pendingOpen: DocumentOpenedDetail | null = null;
/** odoo.pickSave tokens -> binding (services_odoo.rs writes to
* /odoo/pending/<token>/<name>; the first write creates the record and
* every later Ctrl+S writes back in place). */
pendingTokens = new Map<string, PendingTokenBinding>();
constructor() {
this.inFrame = detectInFrame();
window.addEventListener("message", (event) => this.onMessage(event));
window.addEventListener(EVT_HOST_REQUEST, ((event: Event) => {
const detail = (event as CustomEvent).detail as HostRequestDetail | undefined;
if (!detail || typeof detail.id !== "number" || typeof detail.method !== "string") return;
// services_odoo.rs dispatches the request object as `params`; the
// orchestration glue historically used `payload`. Accept both.
const raw = detail.payload !== undefined ? detail.payload : detail.params;
this.enqueue({
id: detail.id,
method: detail.method,
payload: (raw && typeof raw === "object"
? raw
: {}) as Record<string, unknown>,
});
}) as EventListener);
}
/* ---------------- host plumbing ---------------- */
isHostAvailable(): boolean {
return this.inFrame && !this.standalone;
}
private onMessage(event: MessageEvent): void {
const message = event.data as
| { source?: string; kind?: string; id?: number; ok?: boolean; result?: unknown; error?: { message?: string } | null }
| null;
if (
!message ||
typeof message !== "object" ||
message.source !== HOST_SOURCE_TAG ||
message.kind !== BRIDGE_KIND ||
typeof message.id !== "number"
) {
return;
}
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.ok) pending.resolve(message.result);
else pending.reject(new Error(message.error?.message || "Odoo Documents bridge call failed"));
}
/** One correlation id per call; 60 s default / 300 s for writes. */
call<T>(method: string, payload?: Record<string, unknown>, options?: CallOptions): Promise<T> {
if (!this.inFrame) {
this.standalone = true;
return Promise.reject(
new Error(`OneCAD Simulation odoo bridge: standalone (no Odoo host) — "${method}" unavailable`),
);
}
const timeoutMs = (options && options.timeoutMs) || DEFAULT_TIMEOUT_MS;
const id = this.nextId++;
const envelope = {
source: SOURCE_TAG,
kind: BRIDGE_KIND,
id,
method,
payload: payload || {},
};
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`OneCAD Simulation odoo bridge: "${method}" timed out after ${timeoutMs} ms`));
}, timeoutMs);
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
// The framework shell is our parent window (same origin).
window.parent.postMessage(envelope, window.location.origin);
});
}
/** Ping every 100 ms until the host answers (mount-order independence). */
async waitForHost(timeoutMs: number = HOST_WAIT_MS): Promise<PingResult> {
if (!this.inFrame) {
this.standalone = true;
return { user: "", folder: 0, app: { root: 0 } };
}
const deadline = Date.now() + timeoutMs;
let lastError: Error | null = null;
while (Date.now() < deadline) {
try {
const pong = await this.ping();
this.standalone = false;
return pong;
} catch (error) {
lastError = error as Error;
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
}
throw new Error(
`OneCAD Simulation odoo bridge: host did not answer ping within ${timeoutMs} ms (${messageOf(lastError)})`,
);
}
/* ---------------- raw host calls ---------------- */
ping(): Promise<PingResult> {
return this.call<PingResult>("ping");
}
list(): Promise<ListResult> {
return this.call<ListResult>("list");
}
stat(recordId: number): Promise<StatResult> {
return this.call<StatResult>("stat", { recordId });
}
read(recordId: number): Promise<ReadResult> {
return this.call<ReadResult>("read", { recordId }, { timeoutMs: WRITE_TIMEOUT_MS });
}
write(payload: { recordId?: number; name?: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
return this.call<WriteResult>("write", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
}
delete(recordId: number): Promise<{ deleted: boolean }> {
return this.call<{ deleted: boolean }>("delete", { recordId });
}
usage(): Promise<UsageResult> {
return this.call<UsageResult>("usage");
}
folders(): Promise<FoldersResult> {
return this.call<FoldersResult>("folders");
}
createFolder(payload: { name: string; parentId?: number }): Promise<{ id: number }> {
return this.call<{ id: number }>("createFolder", payload);
}
listFolder(folderId: number): Promise<{ folders: FolderRow[]; files: FileRow[] }> {
return this.call<{ folders: FolderRow[]; files: FileRow[] }>("listFolder", { folderId });
}
saveExport(payload: { name: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
return this.call<WriteResult>("saveExport", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
}
/* ---------------- binding cache (localStorage) ---------------- */
getCurrentBinding(): CurrentBinding {
return this.binding;
}
/** document name -> Odoo record id, keyed by app + document name. */
bindLookup(name: string): BindEntry | null {
try {
const raw = window.localStorage.getItem(BIND_KEY_PREFIX + stripOwnedExtension(name));
if (!raw) return null;
const entry = JSON.parse(raw) as BindEntry;
return entry && typeof entry.recordId === "number" ? entry : null;
} catch {
return null;
}
}
bindSet(name: string, recordId: number): void {
this.binding = { documentId: recordId, name: stripOwnedExtension(name) };
try {
const entry: BindEntry = {
recordId,
name: stripOwnedExtension(name),
updatedAt: new Date().toISOString(),
};
window.localStorage.setItem(BIND_KEY_PREFIX + entry.name, JSON.stringify(entry));
} catch {
/* private mode — memory-only binding still works for this session */
}
}
/* ---------------- document flows ---------------- */
/** Ctrl+S style write-back / create. Guard runs BEFORE base64. */
async saveDocument(bytes: Uint8Array | ArrayBuffer, name: string, recordId?: number): Promise<WriteResult> {
const raw = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
guardCap(raw);
const dataB64 = bytesToBase64(raw);
const clean = ensureExtension(name, OWNED_EXTENSION);
const result = await this.write({ recordId, name: clean, dataB64 });
this.bindSet(result.name || clean, result.id);
return result;
}
/** Deep-link read; also refreshes the current binding. */
async readDocument(recordId: number): Promise<ReadResult> {
const result = await this.read(recordId);
this.bindSet(result.name, recordId);
return result;
}
/**
* ?document=<id> boot deep link. Never throws (boot must proceed);
* on success stores pendingOpen, fires "onecadsim:document-opened" and
* refreshes the binding so host-ready can carry the metadata.
*/
async openDeepLink(search?: string): Promise<boolean> {
let params: URLSearchParams;
try {
params = new URLSearchParams(search === undefined ? window.location.search : search);
} catch {
return false;
}
const rawId = params.get("document");
if (!rawId) return false;
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) {
console.warn("[onecadsim-odoo] ignoring malformed deep link ?document=" + rawId);
return false;
}
try {
const opened = await this.readDocument(id);
this.pendingOpen = { documentId: id, name: opened.name, dataB64: opened.dataB64 };
window.dispatchEvent(
new CustomEvent<DocumentOpenedDetail>(EVT_DOCUMENT_OPENED, { detail: this.pendingOpen }),
);
return true;
} catch (error) {
console.warn("[onecadsim-odoo] deep-link read failed for document " + id + ":", messageOf(error));
return false;
}
}
/** Late-boot consumers fetch the deep-link payload once, then null. */
consumePendingOpen(): DocumentOpenedDetail | null {
const pending = this.pendingOpen;
this.pendingOpen = null;
return pending;
}
setExplorerOpener(opener: ExplorerOpener): void {
this.opener = opener;
}
private async askExplorer(options: {
mode: "save" | "open" | "asset" | "export";
defaultName?: string;
extensions?: string[];
}): Promise<ExplorerChoice | null> {
if (!this.isHostAvailable()) {
throw new Error("OneCAD Simulation odoo bridge: standalone (no Odoo host) — explorer unavailable");
}
if (!this.opener) {
throw new Error("OneCAD Simulation odoo bridge: no explorer dialog wired (setExplorerOpener)");
}
return this.opener(options);
}
private async appSerialize(): Promise<Uint8Array> {
const api = onecadsimApi();
if (!api || !api.request) {
throw new Error(
"OneCAD Simulation odoo bridge: no document bytes given and window.onecadsim is unavailable",
);
}
const out = await api.request(APP_SERIALIZE_METHOD);
const bytes = normalizeBytes(out);
if (!bytes) {
throw new Error("OneCAD Simulation odoo bridge: window.onecadsim did not return document bytes");
}
return bytes;
}
private async appInfo(): Promise<{ name?: string }> {
const api = onecadsimApi();
if (!api || !api.request) return {};
try {
const info = (await api.request(APP_INFO_METHOD)) as { name?: unknown } | null;
return info && typeof info.name === "string" ? { name: info.name } : {};
} catch {
return {};
}
}
/** Save-as flow: bytes -> explorer (tree + name) -> write -> bind. */
async saveAs(options?: {
bytes?: Uint8Array | ArrayBuffer;
dataB64?: string;
name?: string;
recordId?: number;
}): Promise<SaveAsResult | null> {
const opts = options || {};
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
if (!dataB64) {
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
guardCap(raw);
dataB64 = bytesToBase64(raw);
}
const info = await this.appInfo();
const suggested = opts.name || info.name || "untitled";
const choice = await this.askExplorer({
mode: "save",
defaultName: ensureExtension(suggested, OWNED_EXTENSION),
});
if (!choice || !choice.name) return null; // cancelled
const name = ensureExtension(choice.name, OWNED_EXTENSION);
const result = await this.write({
recordId: opts.recordId,
name,
dataB64,
folderId: choice.folderId,
});
this.bindSet(result.name || name, result.id);
return { id: result.id, name: result.name || name, folderId: choice.folderId || null };
}
/** Open flow: explorer (.fieldcraft filter) -> read 300 s. */
async openFromDocuments(): Promise<OpenResult | null> {
const choice = await this.askExplorer({ mode: "open", extensions: ["fieldcraft", "solidcraft"] });
if (!choice || typeof choice.recordId !== "number") return null; // cancelled
const opened = await this.readDocument(choice.recordId);
return { id: choice.recordId, name: opened.name, dataB64: opened.dataB64 };
}
/** Export flow: bytes -> (optional name dialog) -> saveExport -> Exports. */
async exportTo(options?: {
bytes?: Uint8Array | ArrayBuffer;
dataB64?: string;
name?: string;
}): Promise<SavedResult | null> {
const opts = options || {};
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
if (!dataB64) {
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
guardCap(raw);
dataB64 = bytesToBase64(raw);
}
let name = (opts.name || "").trim();
let folderId: number | undefined;
if (!name) {
const info = await this.appInfo();
const choice = await this.askExplorer({
mode: "export",
defaultName: (info.name || "export") + ".step",
});
if (!choice || !choice.name) return null; // cancelled
name = choice.name;
folderId = choice.folderId;
}
const result = await this.saveExport({ name, dataB64, folderId });
return { id: result.id, name: result.name || name };
}
/** Import flow: file picker (or given bytes) -> write into Assets. */
async importAsset(options?: {
file?: File;
bytes?: Uint8Array | ArrayBuffer;
dataB64?: string;
name?: string;
}): Promise<SavedResult | null> {
const opts = options || {};
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
let name = (opts.name || "").trim();
let folderId: number | undefined;
if (!dataB64 && !opts.file && !opts.bytes) {
const choice = await this.askExplorer({ mode: "asset" });
if (!choice || !(choice.file instanceof File)) return null; // cancelled
folderId = choice.folderId;
const raw = new Uint8Array(await choice.file.arrayBuffer());
guardCap(raw);
dataB64 = bytesToBase64(raw);
name = name || choice.file.name;
} else if (!dataB64) {
const raw = opts.bytes
? opts.bytes instanceof Uint8Array
? opts.bytes
: new Uint8Array(opts.bytes)
: new Uint8Array(await (opts.file as File).arrayBuffer());
guardCap(raw);
dataB64 = bytesToBase64(raw);
name = name || (opts.file ? opts.file.name : "asset");
}
if (!folderId) {
const app = (await this.ping()).app;
folderId = app.assets || app.root;
}
const result = await this.write({ name: name || "asset", dataB64, folderId });
return { id: result.id, name: result.name || name || "asset" };
}
/* ---------------- Rust event queue ---------------- */
private enqueue(entry: QueueEntry): void {
this.queue.push(entry);
void this.drainQueue();
}
/** Strictly FIFO, one request at a time; every id answered exactly once. */
private async drainQueue(): Promise<void> {
if (this.draining) return;
this.draining = true;
while (this.queue.length > 0) {
const entry = this.queue.shift() as QueueEntry;
try {
const result = await this.runRequest(entry.method, entry.payload);
this.respond(entry.id, true, result, null);
} catch (error) {
this.respond(entry.id, false, null, { message: messageOf(error) });
}
}
this.draining = false;
}
private respond(id: number, ok: boolean, result: unknown, error: { message: string } | null): void {
const detail: HostResponseDetail = { id, ok, result, error };
window.dispatchEvent(new CustomEvent<HostResponseDetail>(EVT_HOST_RESPONSE, { detail }));
}
private async runRequest(method: string, payload: Record<string, unknown>): Promise<unknown> {
if (method === "odoo.pickOpen" || method === "odoo.pickProgram") return this.pickForRust(method, payload);
if (method === "odoo.pickSave") return this.pickSaveForRust(payload);
if (method === "odoo.write") return this.rustWrite(payload);
if (method === "odoo.saveExport") return this.rustSaveExport(payload);
if (method === "odoo.import") return this.rustImport(payload);
if (method === "saveAs") {
return this.saveAs({
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
name: typeof payload.name === "string" ? payload.name : undefined,
recordId: typeof payload.recordId === "number" ? payload.recordId : undefined,
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
});
}
if (method === "open") return this.openFromDocuments();
if (method === "export") {
return this.exportTo({
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
name: typeof payload.name === "string" ? payload.name : undefined,
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
});
}
if (method === "importAsset") {
return this.importAsset({
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
name: typeof payload.name === "string" ? payload.name : undefined,
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
file: payload.file instanceof File ? payload.file : undefined,
});
}
if (HOST_METHODS.has(method)) {
const timeoutMs = BULK_METHODS.has(method) ? WRITE_TIMEOUT_MS : undefined;
return this.call<unknown>(method, payload, { timeoutMs });
}
throw new Error("OneCAD Simulation odoo bridge: unknown host-request method \"" + method + "\"");
}
/* ------- services_odoo.rs wire protocol (CustomEvent round trip) ------- */
/** odoo.pickOpen / odoo.pickProgram -> {recordId, name, bytes} | null. */
private async pickForRust(method: string, payload: Record<string, unknown>): Promise<unknown> {
const requested = Array.isArray(payload.extensions) && payload.extensions.length
? (payload.extensions as string[])
: null;
const extensions = requested || (method === "odoo.pickProgram" ? RUST_ASSET_EXTS : ["fieldcraft", "solidcraft"]);
const choice = await this.askExplorer({ mode: "open", extensions });
if (!choice || typeof choice.recordId !== "number") return null;
const opened = await this.readDocument(choice.recordId);
return {
recordId: choice.recordId,
name: opened.name || choice.recordName || "document",
bytes: base64ToBytes(opened.dataB64),
};
}
/** odoo.pickSave -> {token, name}; the token binds the later write-back. */
private async pickSaveForRust(payload: Record<string, unknown>): Promise<unknown> {
const design = payload.design !== false;
const suggested = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : "untitled";
const choice = await this.askExplorer({
mode: "save",
defaultName: design ? ensureExtension(suggested, OWNED_EXTENSION) : suggested,
});
if (!choice || !choice.name) return null;
const token = newToken();
const name = sanitizeName(choice.name);
this.pendingTokens.set(token, { recordId: null, name, folderId: choice.folderId || null });
return { token, name };
}
/** odoo.write: /odoo/doc/<rid>/<name> in place, /odoo/pending/<token>/<name> first save. */
private async rustWrite(payload: Record<string, unknown>): Promise<unknown> {
const bytes = bytesOf(payload);
if (!bytes) throw new Error("odoo.write: no bytes given");
guardCap(bytes);
const dataB64 = bytesToBase64(bytes);
const path = typeof payload.path === "string" ? payload.path : "";
if (path.startsWith("/odoo/pending/")) {
const rest = path.slice("/odoo/pending/".length);
const slash = rest.indexOf("/");
const token = slash > 0 ? rest.slice(0, slash) : "";
const pathName = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
const bind = token ? this.pendingTokens.get(token) : undefined;
const name = (bind ? bind.name : "") || pathName;
let recordId = bind ? bind.recordId : null;
if (!recordId) {
const remembered = this.bindLookup(name);
recordId = remembered ? remembered.recordId : null;
}
const result = await this.write({ recordId, name, dataB64, folderId: bind ? bind.folderId : undefined });
if (bind) bind.recordId = result.id;
else if (token) this.pendingTokens.set(token, { recordId: result.id, name, folderId: null });
this.bindSet(result.name || name, result.id);
return { id: result.id, name: result.name || name };
}
if (path.startsWith("/odoo/doc/")) {
const rest = path.slice("/odoo/doc/".length);
const slash = rest.indexOf("/");
const recordId = slash > 0 ? Number(rest.slice(0, slash)) : 0;
const name = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
if (!Number.isInteger(recordId) || recordId <= 0) {
throw new Error("odoo.write: bad record path \"" + path + "\"");
}
const result = await this.write({ recordId, name, dataB64 });
this.bindSet(result.name || name, recordId);
return { id: result.id, name: result.name || name };
}
throw new Error("odoo.write: unsupported path \"" + path + "\"");
}
/** odoo.saveExport: {name, bytes} -> Documents "Exports" folder. */
private async rustSaveExport(payload: Record<string, unknown>): Promise<unknown> {
const bytes = bytesOf(payload);
if (!bytes) throw new Error("odoo.saveExport: no bytes given");
guardCap(bytes);
const result = await this.saveExport({ name: sanitizeName(payload.name) || "export", dataB64: bytesToBase64(bytes) });
return { id: result.id, name: result.name };
}
/** odoo.import: mirror a computer import into the Documents "Assets" folder. */
private async rustImport(payload: Record<string, unknown>): Promise<unknown> {
const bytes = bytesOf(payload);
if (!bytes) throw new Error("odoo.import: no bytes given");
guardCap(bytes);
const app = (await this.ping()).app;
const result = await this.write({ name: payload.name || "asset", dataB64: bytesToBase64(bytes), folderId: app.assets || app.root });
return { id: result.id, name: result.name };
}
}
/** The process-wide singleton (one bridge per app window). */
export const bridge = new OneCadBridge();
/** Family export: the app glue and dialogs always share this instance. */
export function exportForHost(): OneCadBridge {
return bridge;
}
-719
View File
@@ -1,719 +0,0 @@
// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
// OneCAD Simulation Odoo Documents glue (ported from OneCAD).
/**
* OneCAD Simulation odoo/explorer.js — the unified Documents explorer dialogs.
* ----------------------------------------------------------------------
* Vanilla-JS (ES module, zero deps) port of the family gold-standard
* explorer (one-photo src/odoo-host/explorer.js — same layout, same
* interactions, same last-folder memory), restyled with an injected dark
* CAD palette (no CSS imports — one <style> tag) so it reads native over
* the egui canvas. Four dialogs, one explorer:
*
* save — "Save to Documents": folder tree + name input (.fieldcraft chip)
* open — "Open from Documents": folder tree + files filtered to .fieldcraft
* asset — "Import asset": file picker -> Assets folder (+ retarget)
* export — "Export": name input -> Exports folder
*
* left — My Drive folder tree (recursive, expandable, usage footer)
* right — breadcrumb + folder/file rows (icon, name, size)
* footer — Cancel left, primary action right, Esc / backdrop cancels
*
* Everything runs through the bridge (odoo/bridge.js — compiled from
* bridge.ts): listFolder / createFolder / folders / usage. No framework.
*
* @returns per mode (resolve value, null = cancelled):
* save -> {name, folderId}
* open -> {recordId, recordName}
* asset -> {file, folderId, name}
* export -> {name, folderId}
*/
import { bridge } from "./bridge.js?v=20.0.1.0.0";
const STYLE_ID = "ocs-explorer-style";
const MODES = {
save: {
heading: "Save to Documents",
description: "Choose a folder in your Odoo drive for this .fieldcraft document.",
action: "Save",
prefer: "root",
lastKey: "one-cad-sim:documents:last-folder:save",
},
open: {
heading: "Open from Documents",
description: "Browse your Odoo Documents drive and pick a .fieldcraft document.",
action: "Open",
prefer: "root",
lastKey: "one-cad-sim:documents:last-folder:open",
extensions: new Set(["fieldcraft", "solidcraft"]),
},
asset: {
heading: "Import asset",
description: "Pick a CAD asset from your computer to copy into the Assets folder.",
action: "Import",
prefer: "assets",
lastKey: "one-cad-sim:documents:last-folder:asset",
extensions: new Set([
"step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf",
"dxf", "svg", "gltf", "glb", "ply", "urdf",
]),
},
export: {
heading: "Export",
description: "Name the export — it lands in your Documents Exports folder.",
action: "Export",
prefer: "exports",
lastKey: "one-cad-sim:documents:last-folder:export",
},
};
/* ------------------------------------------------------------------ */
/* Small SVG icon set (lucide-style, stroke = currentColor) */
/* ------------------------------------------------------------------ */
function svg(paths, size) {
return (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" ' +
'style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex:none">' +
paths + "</svg>"
);
}
const ICONS = {
folder: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'),
lock: svg('<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', 11),
chevronRight: svg('<path d="m9 18 6-6-6-6"/>', 12),
hardDrive: svg('<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>'),
file: svg('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>'),
cube: svg('<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
save: svg('<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 1-1 1H7"/>'),
folderPlus: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M12 10v6"/><path d="M9 13h6"/>'),
upload: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>'),
open: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="m2 21 5.1-5.1a2 2 0 0 1 2.8 0l1.1 1.1a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 1 2.8 0L22 14"/>'),
download: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>'),
};
/* ------------------------------------------------------------------ */
/* Injected dark CAD styles (the only CSS this module ever adds) */
/* ------------------------------------------------------------------ */
const STYLE_CSS = [
":root{--ocs-bg:#1b1e24;--ocs-panel:#23272f;--ocs-panel2:#2a2f39;--ocs-hairline:#333945;",
"--ocs-text:#e8eaed;--ocs-dim:#9aa3ad;--ocs-accent:#4f8cff;--ocs-accent-press:#3f76e0;",
"--ocs-danger:#e5534b;--ocs-ok:#3fb26f}",
".ocs-explorer-root{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;",
"font:13px/1.45 'Segoe UI',system-ui,-apple-system,sans-serif;color:var(--ocs-text)}",
".ocs-backdrop{position:absolute;inset:0;background:rgba(8,10,14,.62)}",
".ocs-dialog{position:relative;width:min(860px,calc(100vw - 48px));height:min(560px,calc(100vh - 64px));",
"background:var(--ocs-bg);border:1px solid var(--ocs-hairline);border-radius:10px;",
"box-shadow:0 24px 64px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
".ocs-header{padding:14px 18px 10px;border-bottom:1px solid var(--ocs-hairline);background:var(--ocs-panel)}",
".ocs-title{font-size:15px;font-weight:600}",
".ocs-desc{margin-top:2px;color:var(--ocs-dim);font-size:12px}",
".ocs-desc-error{color:var(--ocs-danger)}",
".ocs-body{flex:1;display:flex;min-height:0}",
".ocs-aside{width:250px;flex:none;display:flex;flex-direction:column;border-right:1px solid var(--ocs-hairline);background:var(--ocs-panel)}",
".ocs-tree-box{flex:1;overflow:auto;padding:8px 6px}",
".ocs-tree-label{margin:2px 8px 6px;color:var(--ocs-dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}",
".ocs-tree-error{padding:8px}",
".ocs-tree-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;white-space:nowrap}",
".ocs-tree-row:hover{background:var(--ocs-panel2)}",
".ocs-tree-row.ocs-active{background:var(--ocs-panel2);color:#fff}",
".ocs-tree-toggle{width:14px;height:14px;flex:none;color:var(--ocs-dim);display:inline-flex;transition:transform .12s}",
".ocs-tree-toggle.ocs-open{transform:rotate(90deg)}",
".ocs-tree-icon{display:inline-flex;color:var(--ocs-accent)}",
".ocs-tree-name{overflow:hidden;text-overflow:ellipsis}",
".ocs-usage{padding:8px 14px;border-top:1px solid var(--ocs-hairline);color:var(--ocs-dim);font-size:11px}",
".ocs-main{flex:1;display:flex;flex-direction:column;min-width:0}",
".ocs-crumbs{display:flex;align-items:center;gap:2px;padding:8px 12px;border-bottom:1px solid var(--ocs-hairline);overflow:hidden}",
".ocs-crumb{display:inline-flex;align-items:center;gap:5px;background:none;border:0;color:var(--ocs-dim);",
"font:inherit;padding:4px 7px;border-radius:6px;cursor:pointer}",
".ocs-crumb:hover:not(:disabled){background:var(--ocs-panel2);color:var(--ocs-text)}",
".ocs-crumb:disabled{opacity:.45;cursor:default}",
".ocs-crumb-current{color:var(--ocs-text)}",
".ocs-crumb-sep{display:inline-flex;color:var(--ocs-dim)}",
".ocs-files{flex:1;overflow:auto;padding:6px 8px}",
".ocs-file-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:0;",
"color:inherit;font:inherit;padding:7px 9px;border-radius:6px;cursor:pointer}",
".ocs-file-row:hover{background:var(--ocs-panel2)}",
".ocs-file-row.ocs-selected{background:rgba(79,140,255,.18);outline:1px solid var(--ocs-accent)}",
".ocs-file-icon{display:inline-flex;color:var(--ocs-dim)}",
".ocs-file-icon.ocs-icon-owned{color:var(--ocs-accent)}",
".ocs-file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".ocs-file-size{color:var(--ocs-dim);font-size:11px;flex:none}",
".ocs-empty{color:var(--ocs-dim);padding:18px 10px;text-align:center}",
".ocs-savebar{border-top:1px solid var(--ocs-hairline);padding:10px 14px;background:var(--ocs-panel)}",
".ocs-name-row{display:flex;align-items:flex-end;gap:10px}",
".ocs-name-wrap{flex:1;min-width:0}",
".ocs-name-label{display:block;color:var(--ocs-dim);font-size:11px;margin-bottom:4px}",
".ocs-name-field{display:flex;align-items:center;background:var(--ocs-bg);border:1px solid var(--ocs-hairline);border-radius:6px;padding:0 8px}",
".ocs-name-field:focus-within{border-color:var(--ocs-accent)}",
".ocs-name-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--ocs-text);font:inherit;padding:7px 0}",
".ocs-suffix{color:var(--ocs-dim);font-size:12px;user-select:none}",
".ocs-create-row{display:flex;gap:8px;margin-top:8px}",
".ocs-newfolder-input{flex:1;background:var(--ocs-bg);border:1px solid var(--ocs-hairline);border-radius:6px;",
"color:var(--ocs-text);font:inherit;padding:6px 9px;outline:0}",
".ocs-newfolder-input:focus{border-color:var(--ocs-accent)}",
".ocs-picker{display:flex;flex-direction:column;gap:8px}",
".ocs-pick-btn{display:inline-flex;align-items:center;gap:8px}",
".ocs-picked{color:var(--ocs-dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".ocs-footer{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;",
"border-top:1px solid var(--ocs-hairline);background:var(--ocs-panel)}",
".ocs-btn{display:inline-flex;align-items:center;gap:6px;font:inherit;padding:7px 14px;border-radius:6px;",
"border:1px solid var(--ocs-hairline);background:var(--ocs-panel2);color:var(--ocs-text);cursor:pointer}",
".ocs-btn:hover:not(:disabled){border-color:var(--ocs-dim)}",
".ocs-btn:disabled{opacity:.45;cursor:default}",
".ocs-btn-accent{background:var(--ocs-accent);border-color:var(--ocs-accent);color:#fff}",
".ocs-btn-accent:hover:not(:disabled){background:var(--ocs-accent-press)}",
".ocs-btn-ghost{background:none;border-color:transparent;color:var(--ocs-dim)}",
".ocs-btn-ghost:hover:not(:disabled){color:var(--ocs-text)}",
".ocs-btn-icon{display:inline-flex}",
].join("\n");
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = STYLE_CSS;
document.head.appendChild(style);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function h(tag, attrs) {
const el = document.createElement(tag);
if (attrs) {
for (const key in attrs) {
if (!Object.prototype.hasOwnProperty.call(attrs, key)) continue;
const value = attrs[key];
if (key === "class") el.className = value;
else if (key === "html") el.innerHTML = value;
else if (key === "text") el.textContent = value;
else if (key.slice(0, 2) === "on" && typeof value === "function")
el.addEventListener(key.slice(2), value);
else if (value !== null && value !== undefined) el.setAttribute(key, value);
}
}
for (let i = 2; i < arguments.length; i++) {
const child = arguments[i];
if (child === null || child === undefined) continue;
el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
}
return el;
}
function formatSize(size) {
size = Number(size) || 0;
if (size < 1024) return size + " B";
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KB";
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + " MB";
return (size / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}
function extensionOf(name) {
const dot = name.lastIndexOf(".");
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
}
function fileIconFor(file, extensions) {
const ext = extensionOf(file.name || "");
if (extensions && extensions.has(ext)) {
return '<span class="ocs-file-icon ocs-icon-owned">' + ICONS.cube + "</span>";
}
return '<span class="ocs-file-icon">' + ICONS.file + "</span>";
}
function rememberLastFolder(folderId, key) {
try {
window.localStorage.setItem(key, String(folderId));
} catch (e) {
/* private mode — memory only */
}
}
function restoreLastFolder(key) {
try {
const id = Number(window.localStorage.getItem(key));
return Number.isInteger(id) && id > 0 ? id : null;
} catch (e) {
return null;
}
}
/* ------------------------------------------------------------------ */
/* The explorer */
/* ------------------------------------------------------------------ */
/**
* Open one Documents explorer dialog.
*
* @param {object} opts
* mode "save" | "open" | "asset" | "export"
* defaultName save/export mode: suggested file name ("part.fieldcraft")
* extensions open/asset mode: restrict file rows to these extensions
* @returns {Promise<{name?, folderId?, recordId?, recordName?, file?} | null>}
* null = cancelled (Esc, backdrop, Cancel button).
*/
export function openDocumentsExplorer(opts) {
opts = opts || {};
const mode = MODES[opts.mode] ? opts.mode : "save";
const config = MODES[mode];
const extensions = opts.extensions && opts.extensions.length
? new Set(opts.extensions.map((ext) => String(ext).toLowerCase().replace(/^\./, "")))
: config.extensions || null;
/* Standalone dev mode: no host, no dialog — the caller treats null as
* "nothing chosen", so the app keeps running untouched. */
if (typeof bridge.isHostAvailable === "function" && !bridge.isHostAvailable()) {
console.info("[onecadsim-odoo] explorer: no Odoo host (standalone) — dialog unavailable");
return Promise.resolve(null);
}
ensureStyle();
/* Save/export name split: stem in the field, extension as the chip. */
const wantsName = mode === "save" || mode === "export";
const defaultExtension = mode === "save" ? ".fieldcraft" : "";
let defaultName = typeof opts.defaultName === "string" && opts.defaultName.trim() !== ""
? opts.defaultName.trim()
: mode === "save"
? "untitled.fieldcraft"
: "export";
let stem = defaultName;
let suffix = "";
const dotIdx = defaultName.lastIndexOf(".");
if (dotIdx > 0) {
stem = defaultName.slice(0, dotIdx);
suffix = defaultName.slice(dotIdx);
}
return new Promise((resolve) => {
let settled = false;
const settle = (result) => {
if (settled) return;
settled = true;
document.removeEventListener("keydown", onDocKeyDown, true);
if (root && root.parentNode) root.parentNode.removeChild(root);
resolve(result);
};
/* ---------------- state ---------------- */
let tree = null;
let treeError = null;
let appTree = {};
let appRootId = null;
let currentFolderId = null;
let folderFolders = [];
let folderFiles = [];
let loadingFiles = false;
let selectedFileId = null;
let pickedFile = null;
let createBusy = false;
let usage = null;
const expandedSet = new Set();
/* ---------------- chrome ---------------- */
const backdrop = h("div", { class: "ocs-backdrop" });
const dialog = h("div", { class: "ocs-dialog", role: "dialog", "aria-modal": "true" });
const root = h("div", { class: "ocs-explorer-root" }, backdrop, dialog);
const descEl = h("div", { class: "ocs-desc" }, config.description);
const header = h("div", { class: "ocs-header" },
h("div", { class: "ocs-title" }, config.heading), descEl);
const treeLabel = h("p", { class: "ocs-tree-label" }, "Loading drive…");
const treeBox = h("div", { class: "ocs-tree-box" }, treeLabel);
const usageEl = h("div", { class: "ocs-usage", style: "display:none" });
const aside = h("div", { class: "ocs-aside" }, treeBox, usageEl);
const crumbs = h("div", { class: "ocs-crumbs" });
const fileList = h("div", { class: "ocs-files" });
const main = h("div", { class: "ocs-main" }, crumbs, fileList);
const body = h("div", { class: "ocs-body" }, aside, main);
/* name row (save + export) */
const nameInput = h("input", {
class: "ocs-name-input",
id: "onecadsim-explorer-name",
type: "text",
value: stem,
placeholder: mode === "save" ? "Untitled" : "export",
spellcheck: "false",
});
const suffixChip = h("span", { class: "ocs-suffix" }, suffix);
const nameField = h("div", { class: "ocs-name-field" }, nameInput, suffixChip);
const nameRow = h("div", { class: "ocs-name-row" },
h("div", { class: "ocs-name-wrap" },
h("label", { class: "ocs-name-label", for: "onecadsim-explorer-name" }, "File name"),
nameField));
/* new-folder row (save + export; asset keeps it too — retargeting) */
const newNameInput = h("input", {
class: "ocs-newfolder-input",
type: "text",
placeholder: "Folder name…",
spellcheck: "false",
});
const createBtn = h("button", { class: "ocs-btn ocs-btn-accent", type: "button" }, "Create");
const cancelCreateBtn = h("button", { class: "ocs-btn ocs-btn-ghost", type: "button" }, "Cancel");
const createRow = h("div", { class: "ocs-create-row", style: "display:none" }, newNameInput, createBtn, cancelCreateBtn);
const newFolderBtn = h("button", { class: "ocs-btn", type: "button" },
h("span", { class: "ocs-btn-icon", html: ICONS.folderPlus }), "New folder");
/* asset file picker */
const fileInput = h("input", {
type: "file",
style: "display:none",
...(extensions ? { accept: Array.from(extensions).map((ext) => "." + ext).join(",") } : {}),
});
const pickedLabel = h("div", { class: "ocs-picked", text: "No file chosen yet." });
const pickBtn = h("button", { class: "ocs-btn ocs-btn-accent", type: "button" },
h("span", { class: "ocs-btn-icon", html: ICONS.upload }), "Choose file…");
const picker = h("div", { class: "ocs-picker" }, pickBtn, pickedLabel, fileInput);
const savebar = h("div", { class: "ocs-savebar" });
if (wantsName) savebar.appendChild(nameRow);
if (mode === "asset") savebar.appendChild(picker);
savebar.appendChild(newFolderBtn);
savebar.appendChild(createRow);
/* footer */
const cancelBtn = h("button", { class: "ocs-btn ocs-btn-ghost", type: "button" }, "Cancel");
const actionIcon = mode === "save" ? ICONS.save : mode === "open" ? ICONS.open : mode === "asset" ? ICONS.upload : ICONS.download;
const actionBtn = h("button", { class: "ocs-btn ocs-btn-accent", type: "button" },
h("span", { class: "ocs-btn-icon", html: actionIcon }), config.action);
const footer = h("div", { class: "ocs-footer" }, cancelBtn, actionBtn);
dialog.appendChild(header);
dialog.appendChild(body);
if (mode !== "open") dialog.appendChild(savebar);
dialog.appendChild(footer);
const redraw = () => {
if (mode === "save") {
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
} else if (mode === "open") {
actionBtn.disabled = selectedFileId === null;
} else if (mode === "asset") {
actionBtn.disabled = !pickedFile || currentFolderId === null;
} else {
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
}
};
/* ---------------- tree rendering ---------------- */
function renderTree() {
treeBox.innerHTML = "";
if (tree === null && !treeError) {
treeBox.appendChild(h("p", { class: "ocs-tree-label" }, "Loading drive…"));
return;
}
if (treeError) {
const retry = h("button", { class: "ocs-btn", type: "button" }, "Retry");
retry.addEventListener("click", () => boot());
treeBox.appendChild(h("div", { class: "ocs-tree-error" },
h("p", { class: "ocs-tree-label" }, treeError), retry));
return;
}
treeBox.appendChild(h("p", { class: "ocs-tree-label" }, "My Drive"));
const childrenOf = new Map();
for (const folder of tree) {
const key = folder.parentId === null ? "root" : String(folder.parentId);
if (!childrenOf.has(key)) childrenOf.set(key, []);
childrenOf.get(key).push(folder);
}
const renderNodes = (parentId, depth) => {
const key = parentId === null ? "root" : String(parentId);
const children = childrenOf.get(key) || [];
for (const folder of children) {
const hasChildren = (childrenOf.get(String(folder.id)) || []).length > 0;
const isOpen = expandedSet.has(folder.id);
const row = h("div", {
class: "ocs-tree-row" + (folder.id === currentFolderId ? " ocs-active" : ""),
style: "padding-left:" + (8 + depth * 14) + "px",
});
const toggle = h("span", { class: "ocs-tree-toggle" });
if (hasChildren) {
toggle.innerHTML = ICONS.chevronRight;
toggle.classList.add("ocs-has-children");
if (isOpen) toggle.classList.add("ocs-open");
}
toggle.addEventListener("click", (ev) => {
ev.stopPropagation();
if (expandedSet.has(folder.id)) expandedSet.delete(folder.id);
else expandedSet.add(folder.id);
renderTree();
});
row.appendChild(toggle);
row.appendChild(h("span", { class: "ocs-tree-icon", html: ICONS.folder }));
row.appendChild(h("span", { class: "ocs-tree-name" }, folder.name));
row.addEventListener("click", () => enterFolder(folder.id));
treeBox.appendChild(row);
if (hasChildren && isOpen) renderNodes(folder.id, depth + 1);
}
};
renderNodes(null, 0);
usageEl.style.display = usage ? "" : "none";
if (usage) {
usageEl.textContent = usage.documentCount + " file" + (usage.documentCount === 1 ? "" : "s") +
" · " + formatSize(usage.bytesUsed) + " used";
}
}
/* ---------------- breadcrumb ---------------- */
function renderCrumbs() {
crumbs.innerHTML = "";
const driveBtn = h("button", { class: "ocs-crumb ocs-crumb-root", type: "button" });
driveBtn.appendChild(h("span", { class: "ocs-btn-icon", html: ICONS.hardDrive }));
driveBtn.appendChild(document.createTextNode("My Drive"));
driveBtn.disabled = appRootId === null;
driveBtn.addEventListener("click", () => appRootId !== null && enterFolder(appRootId));
crumbs.appendChild(driveBtn);
const path = [];
let cursor = currentFolderId;
const byId = new Map(tree ? tree.map((row) => [row.id, row]) : []);
while (cursor !== null && byId.has(cursor)) {
path.unshift(byId.get(cursor));
cursor = byId.get(cursor).parentId;
}
for (const folder of path) {
crumbs.appendChild(h("span", { class: "ocs-crumb-sep", html: ICONS.chevronRight }));
const crumb = h("button", {
class: "ocs-crumb" + (folder.id === currentFolderId ? " ocs-crumb-current" : ""),
type: "button",
}, folder.name);
crumb.addEventListener("click", () => enterFolder(folder.id));
crumbs.appendChild(crumb);
}
}
/* ---------------- files ---------------- */
function renderFiles() {
fileList.innerHTML = "";
if (loadingFiles) {
fileList.appendChild(h("p", { class: "ocs-empty" }, "Loading…"));
return;
}
/* Desktop file-manager behavior: subfolders first (click = enter),
* then files — open mode filtered to the requested extensions. */
for (const folder of folderFolders) {
const row = h("button", { class: "ocs-file-row", type: "button" });
row.insertAdjacentHTML("beforeend", '<span class="ocs-file-icon ocs-icon-owned">' + ICONS.folder + "</span>");
row.appendChild(h("span", { class: "ocs-file-name" }, folder.name));
if (folder.system) {
row.insertAdjacentHTML("beforeend",
'<span class="ocs-file-size" title="Protected OneBSpace folder">' + ICONS.lock + "</span>");
}
row.appendChild(h("span", { class: "ocs-file-size" }, "Folder"));
row.addEventListener("click", () => enterFolder(folder.id));
fileList.appendChild(row);
}
const visibleFiles = extensions
? folderFiles.filter((file) => extensions.has(extensionOf(file.name || "")))
: folderFiles;
if (folderFolders.length === 0 && visibleFiles.length === 0) {
fileList.appendChild(h("p", { class: "ocs-empty" },
mode === "save" || mode === "export" ? "This folder is empty." : "No compatible files in this folder."));
return;
}
if (visibleFiles.length === 0) {
fileList.appendChild(h("p", { class: "ocs-empty" }, "No compatible files here yet."));
return;
}
for (const file of visibleFiles) {
const row = h("button", {
class: "ocs-file-row" + (selectedFileId === file.id ? " ocs-selected" : ""),
type: "button",
});
row.insertAdjacentHTML("beforeend", fileIconFor(file, extensions));
row.appendChild(h("span", { class: "ocs-file-name" }, file.name));
row.appendChild(h("span", { class: "ocs-file-size" }, formatSize(file.size || 0)));
row.addEventListener("click", () => {
selectedFileId = file.id;
renderFiles();
redraw();
});
row.addEventListener("dblclick", () => {
if (mode === "open") settle({ recordId: file.id, recordName: file.name });
});
fileList.appendChild(row);
}
}
/* ---------------- actions ---------------- */
function enterFolder(folderId) {
currentFolderId = folderId;
selectedFileId = null;
rememberLastFolder(folderId, config.lastKey);
loadFiles(folderId);
renderTree();
renderCrumbs();
redraw();
}
function loadFiles(folderId) {
loadingFiles = true;
renderFiles();
bridge
.listFolder(folderId)
.then((listing) => {
folderFiles = listing.files || [];
folderFolders = listing.folders || [];
loadingFiles = false;
renderFiles();
})
.catch((error) => {
folderFiles = [];
folderFolders = [];
loadingFiles = false;
renderFiles();
flashError("Could not list this folder — " + (error && error.message ? error.message : "unknown error"));
});
}
function onCreateFolder() {
const trimmed = newNameInput.value.trim();
if (!trimmed || currentFolderId === null || createBusy) return;
createBusy = true;
createBtn.disabled = true;
bridge
.createFolder({ name: trimmed, parentId: currentFolderId })
.then((created) => {
tree = (tree || []).concat([{ id: created.id, name: trimmed, parentId: currentFolderId }]);
expandedSet.add(currentFolderId);
expandedSet.add(created.id);
newNameInput.value = "";
createRow.style.display = "none";
newFolderBtn.style.display = "";
enterFolder(created.id);
})
.catch((error) => {
flashError("Could not create folder — " + (error && error.message ? error.message : "unknown error"));
})
.finally(() => {
createBusy = false;
createBtn.disabled = false;
});
}
function confirmAction() {
if (mode === "save") {
const name = nameInput.value.trim();
if (!name || currentFolderId === null) return;
settle({ name, folderId: currentFolderId });
} else if (mode === "open") {
const file = folderFiles.find((candidate) => candidate.id === selectedFileId);
if (file) settle({ recordId: file.id, recordName: file.name });
} else if (mode === "asset") {
if (pickedFile && currentFolderId !== null) {
settle({ file: pickedFile, folderId: currentFolderId, name: pickedFile.name });
}
} else {
const name = nameInput.value.trim();
if (!name || currentFolderId === null) return;
settle({ name, folderId: currentFolderId });
}
}
pickBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
pickedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
pickedLabel.textContent = pickedFile
? pickedFile.name + " · " + formatSize(pickedFile.size)
: "No file chosen yet.";
redraw();
});
newFolderBtn.addEventListener("click", () => {
newFolderBtn.style.display = "none";
createRow.style.display = "";
newNameInput.focus();
});
cancelCreateBtn.addEventListener("click", () => {
newNameInput.value = "";
createRow.style.display = "none";
newFolderBtn.style.display = "";
});
createBtn.addEventListener("click", onCreateFolder);
newNameInput.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") onCreateFolder();
});
nameInput.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") confirmAction();
});
nameInput.addEventListener("input", redraw);
cancelBtn.addEventListener("click", () => settle(null));
actionBtn.addEventListener("click", confirmAction);
backdrop.addEventListener("mousedown", () => settle(null));
dialog.addEventListener("mousedown", (ev) => ev.stopPropagation());
function onDocKeyDown(ev) {
if (ev.key !== "Escape") return;
ev.stopPropagation();
settle(null);
}
document.addEventListener("keydown", onDocKeyDown, true);
let errorTimer = null;
function flashError(message) {
descEl.textContent = message;
descEl.classList.add("ocs-desc-error");
if (errorTimer) clearTimeout(errorTimer);
errorTimer = setTimeout(() => {
descEl.textContent = config.description;
descEl.classList.remove("ocs-desc-error");
}, 5000);
}
/* ---------------- boot ---------------- */
function boot() {
tree = null;
treeError = null;
renderTree();
void (async () => {
try {
const folders = await bridge.folders();
tree = folders.tree || [];
appTree = folders.app || {};
appRootId = typeof appTree.root === "number" ? appTree.root : null;
const preferred = typeof appTree[config.prefer] === "number"
? appTree[config.prefer]
: appTree.root;
const restored = restoreLastFolder(config.lastKey);
const known = new Set(tree.map((row) => row.id));
const target = restored && known.has(restored) ? restored : preferred;
currentFolderId = target;
const byId = new Map(tree.map((row) => [row.id, row]));
let cursor = target;
while (cursor !== null) {
expandedSet.add(cursor);
cursor = byId.has(cursor) ? byId.get(cursor).parentId : null;
}
renderTree();
renderCrumbs();
loadFiles(target);
bridge
.usage()
.then((info) => {
usage = info;
renderTree();
})
.catch(() => undefined);
} catch (error) {
treeError = error && error.message ? error.message : "Could not load the drive";
renderTree();
}
redraw();
if (wantsName) {
window.setTimeout(() => {
nameInput.focus();
nameInput.select();
}, 50);
}
})();
}
document.body.appendChild(root);
boot();
});
}
-153
View File
@@ -1,153 +0,0 @@
// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
// OneCAD Simulation Odoo Documents glue (ported from OneCAD).
/**
* OneCAD Simulation odoo/index.ts — the boot glue (bridge <-> explorer <-> Rust app).
* ----------------------------------------------------------------------
* Entry module loaded by index.html ONLY inside the wasm_app_framework
* iframe (standalone runs never import it — see the import guard there).
* Responsibilities:
*
* 1. instantiate the bridge singleton and wire the explorer dialogs
* ("saveAs" / "open" / "export" / "importAsset" host-request methods);
* 2. BOOT GATE: waitForHost() (ping every 100 ms, up to 20 s) — then, if
* location.search carries ?document=<id>, read that record BEFORE the
* app is signaled ready (deep-link open, "onecadsim:document-opened");
* 3. signal "onecadsim:host-ready" {host, documentId, name} exactly once.
* The same state stays readable synchronously at window.onecadsimHost
* (late-booting Rust) together with consumePendingOpen(), which hands
* the deep-link payload {documentId, name, dataB64} over once.
*
* Standalone dev mode: no host frame — no waiting, no deep link, the ready
* signal still fires with host:false so the app boots untouched.
*
* Build note: erasable-syntax-only TS compiled by the OneCAD addon build
* to odoo/index.js next to bridge.js; explorer.js is a plain-JS module
* copied verbatim. Exports nothing.
*/
import { exportForHost, EVT_HOST_READY } from "./bridge.js?v=20.0.1.0.0";
// @ts-ignore — explorer.js is a sibling plain-JS ES module (copied verbatim
// into dist; it imports the compiled bridge.js at runtime).
import { openDocumentsExplorer } from "./explorer.js?v=20.0.1.0.0";
import type { DocumentOpenedDetail, ExplorerOpener, HostReadyDetail } from "./bridge.js";
const bridge = exportForHost();
bridge.setExplorerOpener(openDocumentsExplorer as ExplorerOpener);
/* Late-boot contract surface (Rust registers listeners after this module
* has run, so it polls this synchronously-readable state on init). */
interface OneCadHostWindow {
onecadsimHostReady?: boolean;
onecadsimHost?: {
ready: boolean;
host: boolean;
documentId: number | null;
name: string | null;
consumePendingOpen: () => DocumentOpenedDetail | null;
};
onecadsim?: { request?: (method: string, params?: unknown) => Promise<unknown> };
onecadsimOdoo?: {
bootFile?: (recordId: number, name: string, bytes: Uint8Array) => string | undefined;
};
}
const hostWindow = window as unknown as OneCadHostWindow;
hostWindow.onecadsimHost = {
ready: false,
host: false,
documentId: null,
name: null,
consumePendingOpen: () => bridge.consumePendingOpen(),
};
/* services_odoo.rs polls window.onecadsimHostReady (a plain boolean) before
* dispatching odoo.pickOpen / odoo.pickSave / odoo.write to this glue.
* Set it as soon as the boot attempt finishes (host answered or not): every
* Rust picker parks its slot behind this flag, so an unset flag wedges the
* app's file dialogs for REQUEST_TIMEOUT (120 s) with no dialog on screen. */
function markHostReady(): void {
try {
hostWindow.onecadsimHostReady = true;
} catch (error) {
console.warn("[onecadsim-odoo] could not set onecadsimHostReady:", error);
}
}
/* Deep-link opener: stage the record bytes into the engine VFS via the
* window.onecadsimOdoo.bootFile seam (installed by services_odoo.rs), then ask
* the app to open the staged path through its control API. The wasm app API
* appears asynchronously after boot, so poll briefly for it. */
async function openStagedDeepLink(entry: DocumentOpenedDetail | null): Promise<void> {
if (!entry || typeof entry.dataB64 !== "string") return;
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
const api = hostWindow.onecadsim;
const seam = hostWindow.onecadsimOdoo;
if (api && typeof api.request === "function" && seam && typeof seam.bootFile === "function") {
try {
const bytes = Uint8Array.from(atob(entry.dataB64), (char) => char.charCodeAt(0));
const path = seam.bootFile(entry.documentId, entry.name, bytes);
if (typeof path === "string" && path) {
await api.request("ui.file", { path });
return;
}
} catch (error) {
console.warn(
"[onecadsim-odoo] deep-link open failed:",
error instanceof Error ? error.message : error,
);
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
console.warn("[onecadsim-odoo] deep-link open skipped: the app API never appeared");
}
void (async () => {
let host = false;
if (bridge.inFrame) {
try {
await bridge.waitForHost();
host = true;
} catch (error) {
console.warn(
"[onecadsim-odoo] host did not answer in time — continuing without Odoo Documents:",
error,
);
}
} else {
console.info("[onecadsim-odoo] standalone (no host frame) — Odoo Documents disabled");
}
/* Unblock the Rust pickers first; the deep-link read continues in the
* background and must never keep the file dialogs hostage. */
markHostReady();
let deepLink = false;
if (host) {
try {
deepLink = await bridge.openDeepLink();
} catch (error) {
console.warn("[onecadsim-odoo] deep-link read failed:", error);
}
}
const binding = bridge.getCurrentBinding();
const detail: HostReadyDetail = {
host,
documentId: binding.documentId,
name: binding.name,
};
const state = hostWindow.onecadsimHost;
if (state) {
state.ready = true;
state.host = host;
state.documentId = detail.documentId;
state.name = detail.name;
}
window.dispatchEvent(new CustomEvent<HostReadyDetail>(EVT_HOST_READY, { detail }));
if (deepLink) {
void openStagedDeepLink(bridge.consumePendingOpen());
}
})();
export {};
-708
View File
@@ -1,708 +0,0 @@
// OneBSpace (OneCAD Simulation): Odoo Documents host integration.
//! OneBSpace (OneCAD Simulation): Odoo Documents host integration for the wasm32 build
//! (the `mod services_odoo;` declaration in `main.rs` carries the
//! `#[cfg(target_arch = "wasm32")]` gate).
//!
//! Inside the wasm_app_framework shell every host seam of
//! [`fieldcraft_ui_egui::Services`] is rerouted through the one-cad-simulation JS
//! bridge instead of the browser/device. FieldCraft's pickers are *synchronous*
//! (`Fn(&[Filter]) -> Option<String>`), so unlike the OneCAD port this module does
//! not return futures: the closure fires the bridge request and answers `None`
//! (the UI sees "cancelled"), and the real completion is queued here and drained
//! once per frame by the web shell:
//!
//! - an *open* pick stages the record's bytes in the page filesystem at
//! `/odoo/doc/<recordId>/<name>` and queues `ModelApp::open_path` (the same
//! entry `web.rs::open_arrived` uses, so statuses and worker opens behave);
//! - a *save* pick queues `file.save_as {"path": "/odoo/pending/<token>/<name>"}`;
//! the engine write is air-gapped by [`handle_write`] and the bridge binds the
//! pending token to the Documents record, so the model's file becomes the
//! record and later Ctrl+S writes back in place.
//! - writes drained from the page filesystem under `/odoo/…` (or model files
//! anywhere else) go to the bound Documents record; every other write becomes
//! an export into the "Exports" folder — the air gap keeps bytes off the local
//! device ([`handle_write`]); the web shell checks the autosave/recovery folder
//! *first*, so the localStorage recovery mirror keeps working as upstream.
//!
//! Everything is inert standalone: when the page is not framed by the Odoo shell
//! ([`embedded`]) nothing is overridden and the upstream behavior is untouched.
//!
//! # WIRE PROTOCOL (matches the one-cad-simulation JS bridge: CustomEvents)
//!
//! Rust → JS: `window.dispatchEvent(new CustomEvent("onecadsim:host-request",
//! { detail }))` with `detail = { id: number, method: string, params: object }`.
//!
//! JS → Rust: `window.dispatchEvent(new CustomEvent("onecadsim:host-response",
//! { detail }))` with `detail = { id, ok: boolean, result?: any, error?: string }`.
//!
//! The glue must attach its `onecadsim:host-response` listener *before* the app
//! boots (index.html loads the bridge module ahead of the wasm) and set
//! `window.onecadsimHostReady = true` when listening; until the flag appears
//! requests are retried every [`RETRY_MS`] for [`HOST_DEADLINE`].
//!
//! | method | params | result (on `ok`) |
//! |--------------------|------------------------------------------|------------------|
//! | `odoo.pickOpen` | `{ extensions: string[] }` | `{ recordId, name, bytes: Uint8Array }` or `{ content: <base64> }` → bytes staged at `/odoo/doc/<recordId>/<name>` |
//! | `odoo.pickSave` | `{ name: string, design: boolean }` | `{ token, name }` → `/odoo/pending/<token>/<name>`, or `{ path: "/odoo/…" }` |
//! | `odoo.pickProgram` | `{}` (asset pick from the "Assets" folder) | same shape as `odoo.pickOpen` |
//! | `odoo.write` | `{ path: string, bytes: Uint8Array }` | ack only (`ok`) — Documents record write-back |
//! | `odoo.saveExport` | `{ name: string, bytes: Uint8Array }` | ack only — lands in the "Exports" folder |
//! | `odoo.import` | `{ name: string, bytes: Uint8Array }` | ack only — best-effort mirror into "Assets" |
//!
//! A `null`/absent `result` with `ok: true` means the user cancelled the
//! explorer; nothing is queued and the UI stays as it was.
//!
//! For JS→Rust staging (deep links `?document=<id>`) the glue calls
//! `window.onecadsimOdoo.bootFile(recordId, name, uint8array) -> path`, which
//! inserts the bytes into the page filesystem at `/odoo/doc/<id>/<name>`, then
//! asks the app to open the returned path via `window.onecadsim.request`.
//!
//! `window.onecadsim` is a small promise-based API the glue drives (the OneCAD
//! fork's `api.rs` equivalent, kept minimal):
//!
//! - `request("ui.file", { path })` → queues the open; resolves immediately.
//! - `request("odoo.document.bytes")` → resolves with the current model's
//! `.fieldcraft` bytes ([`Session::file_bytes`], via the shell's frame drain).
//! - `request("odoo.document.info")` → resolves with `{ name }` (the current
//! model's file name, if any).
//!
//! # HOW TO WIRE (exact locations in this tree)
//!
//! 1. `apps/fieldcraft-web/src/main.rs`, module block (tagged
//! `OneBSpace (OneCAD Simulation)`): `#[cfg(target_arch = "wasm32")] mod services_odoo;`
//! 2. `apps/fieldcraft-web/src/web.rs`, the startup closure — immediately after
//! the `services(inbox, ctx)` literal and before `with_services`:
//! `let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());`
//! 3. `apps/fieldcraft-web/src/web.rs` `Shell::logic` (tagged line):
//! `crate::services_odoo::drain(&mut self.app);` — completes queued picks.
//! 4. `apps/fieldcraft-web/src/web.rs` `Shell::hand_out_writes` (tagged): the
//! `else if crate::services_odoo::handle_write(&path, &bytes) { continue; }`
//! branch between the recovery-folder branch and the download branch.
//! 5. `apps/fieldcraft-web/src/web.rs` `Shell::open_arrived` (tagged):
//! `crate::services_odoo::mirror_import(&name, …)` mirrors computer imports
//! into the Documents "Assets" folder.
// OneBSpace (OneCAD Simulation) — this entire file is downstream; keep it out of
// upstream syncs (greppable tag above).
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::JsFuture;
/// An existing Documents record inside the emulated filesystem.
const ODOC: &str = "/odoo/doc/";
/// A Save-as destination chosen in the explorer, not yet written.
const OPENDING: &str = "/odoo/pending/";
/// Scratch area whose writes the shell swallows (never a record, never a download).
const OTMP: &str = "/odoo/tmp/";
/// Rust → JS request event (see the WIRE PROTOCOL block above).
const REQUEST_EVENT: &str = "onecadsim:host-request";
/// JS → Rust response event (see the WIRE PROTOCOL block above).
const RESPONSE_EVENT: &str = "onecadsim:host-response";
const PICK_OPEN: &str = "odoo.pickOpen";
const PICK_SAVE: &str = "odoo.pickSave";
/// Supported by the JS bridge; FieldCraft's `Services` has no program picker,
/// so the Rust side never emits it today.
#[allow(dead_code)]
const PICK_PROGRAM: &str = "odoo.pickProgram";
const WRITE: &str = "odoo.write";
const SAVE_EXPORT: &str = "odoo.saveExport";
const IMPORT: &str = "odoo.import";
/// At most this many bridge requests parked at once.
const MAX_PENDING: usize = 64;
/// Retry cadence while `window.onecadsimHostReady` has not appeared yet.
const RETRY_MS: i32 = 250;
/// How long to wait for the JS bridge module to become ready per request
/// (in retry ticks of [`RETRY_MS`]: 30 s).
const HOST_DEADLINE_TICKS: u32 = 120;
/// CAE/model formats the app ingests beyond its own (mirrors the JS bridge's
/// asset list). Imported copies land in the Documents "Assets" folder.
const ASSET_EXTS: [&str; 12] = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
thread_local! {
/// Decided once: does this page run inside the Odoo shell?
static EMBEDDED: Cell<Option<bool>> = const { Cell::new(None) };
/// Pending picker requests, keyed by request id.
static PENDING: RefCell<HashMap<u64, Arc<Slot>>> = RefCell::new(HashMap::new());
/// Completions waiting for the shell's next frame ([`drain`]).
static COMPLETED: RefCell<Vec<Completion>> = RefCell::new(Vec::new());
/// Promise resolvers parked for `odoo.document.bytes`, keyed by request id.
static BYTES_WAITING: RefCell<HashMap<u64, Function>> = RefCell::new(HashMap::new());
/// Promise resolvers parked for `odoo.document.info`, keyed by request id.
static INFO_WAITING: RefCell<HashMap<u64, Function>> = RefCell::new(HashMap::new());
/// The request-id counter.
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
/// The `onecadsim:host-response` listener is installed exactly once.
static LISTENER: Cell<bool> = const { Cell::new(false) };
/// The `window.onecadsimOdoo` boot seam is installed exactly once.
static BOOT: Cell<bool> = const { Cell::new(false) };
/// The `window.onecadsim` API is installed exactly once.
static API: Cell<bool> = const { Cell::new(false) };
/// Asset imports already mirrored this session (name, size).
static MIRRORED: RefCell<HashSet<(String, usize)>> = RefCell::new(HashSet::new());
}
/// A picker request parked in flight: the response listener removes it from
/// [`PENDING`] and pushes the queued completion for [`drain`] (the sync
/// `Services` design has no future to poll a slot, so the queue is the handoff).
struct Slot {
done: Cell<bool>,
}
/// Work the shell performs on its next frame.
enum Completion {
/// Open the emulated path (`ModelApp::open_path`).
Open(String),
/// Save the model to the emulated path (`file.save_as`).
SaveAs(String),
/// Resolve `odoo.document.bytes` with the model's bytes.
Bytes(u64),
/// Resolve `odoo.document.info` with the model's file name.
Info(u64),
}
/// Is this page running inside the wasm_app_framework shell? Decided once,
/// the family rule (one-cut-pro env.ts): framed and the same-origin parent
/// is the Odoo web client (`/odoo` or `/web`). `?odoo-host=1` forces it on
/// for local testing against a stub shell.
pub fn embedded() -> bool {
if let Some(decided) = EMBEDDED.with(Cell::get) {
return decided;
}
let decided = embedded_uncached();
EMBEDDED.with(|e| e.set(Some(decided)));
decided
}
fn embedded_uncached() -> bool {
let Some(window) = web_sys::window() else { return false };
let Ok(search) = window.location().search() else { return false };
if search.contains("odoo-host=1") {
return true;
}
let Ok(parent) = window.parent() else { return false };
// Top window: standalone run, upstream behavior everywhere.
let parent_top = parent
.as_ref()
.map(|p| {
AsRef::<JsValue>::as_ref(p) == AsRef::<JsValue>::as_ref(&window)
})
.unwrap_or(false);
if parent_top {
return false;
}
// Same-origin shell: the parent path is readable; anything else that is
// framed counts as embedded (the shell never cross-origins the iframe).
let pathname = parent
.as_ref()
.and_then(|p| p.location().pathname().ok());
match pathname {
Some(path) => path.starts_with("/odoo") || path.starts_with("/web"),
None => true,
}
}
/// Install the integration: the seams and the response listener always
/// (harmless standalone), the Services overrides only inside the shell.
/// Call once at app start, right after the `services(inbox, ctx)` literal in
/// `web.rs` and before `with_services` (see HOW TO WIRE above).
pub fn install_odoo_services(services: Services, ctx: egui::Context) -> Services {
install_response_listener(ctx.clone());
install_seams(ctx.clone());
if !embedded() {
return services;
}
log::info!("OneCAD Simulation: Odoo shell detected — pickers and writes route through the Documents bridge");
let open_ctx = ctx.clone();
let save_ctx = ctx;
Services {
// Open/Insert pickers browse Odoo Documents: fire the explorer request
// and answer `None` (the shell completes the open on a later frame).
pick_open: Some(Box::new(move |filters: &[Filter]| {
let params = Object::new();
let list = Array::new();
for ext in filter_extensions(filters) {
list.push(&JsValue::from_str(&ext));
}
let _ = Reflect::set(&params, &"extensions".into(), &list.into());
pick(PICK_OPEN, params, open_ctx.clone());
None
})),
// Saves ask the explorer once; the returned pending token binds the
// model to its record (the queued `file.save_as` lands it, later
// Ctrl+S writes back in place).
pick_save: Some(Box::new(move |name: &str, filters: &[Filter]| {
let params = Object::new();
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
// A model save (the owned `.fieldcraft` type) drives the owned save flow.
let design = filters.iter().any(|f| f.extensions.iter().any(|e| e.eq_ignore_ascii_case("fieldcraft")));
let _ = Reflect::set(&params, &"design".into(), &JsValue::from_bool(design));
pick(PICK_SAVE, params, save_ctx.clone());
None
})),
// Air gap: no external links from the shell.
open_url: Some(Box::new(|_: &str| Ok(()))),
..services
}
}
/// The union of a picker filter set's extensions, lowercase, deduped, in order.
fn filter_extensions(filters: &[Filter]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for filter in filters {
for ext in filter.extensions {
let ext = ext.to_ascii_lowercase();
if !out.iter().any(|seen| *seen == ext) {
out.push(ext);
}
}
}
out
}
/// Fire a bridge request (retried until the glue is ready); the answer arrives
/// out-of-band via `onecadsim:host-response` and completes a queued pick.
fn pick(method: &'static str, params: Object, ctx: egui::Context) {
if PENDING.with(|p| p.borrow().len()) >= MAX_PENDING {
log::error!("OneCAD Simulation: too many pending bridge requests (maximum {MAX_PENDING})");
return;
}
let id = next_id();
let slot = Arc::new(Slot { done: Cell::new(false) });
PENDING.with(|p| p.borrow_mut().insert(id, slot));
deliver(method, id, params, 0);
// Nudge the UI so the shell's frame drain runs promptly.
ctx.request_repaint();
}
fn next_id() -> u64 {
NEXT_ID.with(|n| {
let id = n.get();
n.set(id + 1);
id
})
}
/// Deliver a request once `window.onecadsimHostReady` appears (the glue sets it
/// after attaching its response listener), then cancel the slot if it never
/// does. Answers arrive out-of-band via `onecadsim:host-response`.
fn deliver(method: &'static str, id: u64, params: Object, tick: u32) {
wasm_bindgen_futures::spawn_local(async move {
let Some(window) = web_sys::window() else { return };
let slot_done = PENDING.with(|p| p.borrow().get(&id).map(|s| s.done.get()).unwrap_or(true));
if slot_done {
return; // answered or cancelled while we were waiting
}
if host_ready() && dispatch_event(method, id, &params) {
return; // the shell owns the answer now
}
if tick >= HOST_DEADLINE_TICKS {
log::error!("OneCAD Simulation: the Odoo bridge never became ready; cancelling \"{method}\"");
PENDING.with(|p| p.borrow_mut().remove(&id));
return;
}
let promise = js_sys::Promise::new(&mut |resolve, _| {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, RETRY_MS);
});
let _ = JsFuture::from(promise).await;
deliver(method, id, params, tick + 1);
});
}
fn host_ready() -> bool {
web_sys::window()
.map(|w| Reflect::get(&w, &"onecadsimHostReady".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false))
.unwrap_or(false)
}
/// `window.dispatchEvent(new CustomEvent(method, { detail: {id, method, params} }))`.
/// Built through `js_sys` (no extra web-sys feature gates). `false` when the
/// event could not be created or dispatched (no shell listening).
fn dispatch_event(method: &str, id: u64, params: &Object) -> bool {
let Some(window) = web_sys::window() else { return false };
let Ok(ctor) = Reflect::get(&window, &"CustomEvent".into()) else { return false };
let Ok(ctor) = ctor.dyn_into::<js_sys::Function>() else { return false };
let detail = Object::new();
let _ = Reflect::set(&detail, &"id".into(), &JsValue::from(id as f64));
let _ = Reflect::set(&detail, &"method".into(), &JsValue::from_str(method));
let _ = Reflect::set(&detail, &"params".into(), params);
let init = Object::new();
let _ = Reflect::set(&init, &"detail".into(), &detail.into());
let args = Array::of2(&JsValue::from_str(REQUEST_EVENT), &init.into());
let Ok(event) = Reflect::construct(&ctor, &args) else { return false };
let Ok(dispatch) = Reflect::get(&window, &"dispatchEvent".into()) else { return false };
let Ok(dispatch) = dispatch.dyn_into::<js_sys::Function>() else { return false };
dispatch.call1(&window, &event).is_ok()
}
/// Install the `onecadsim:host-response` listener once: responses complete the
/// parked picker slots (queued work for [`drain`]) or log write/import failures.
fn install_response_listener(ctx: egui::Context) {
if LISTENER.with(Cell::get) {
return;
}
let Some(window) = web_sys::window() else { return };
let listener = Closure::<dyn Fn(JsValue)>::new(move |event: JsValue| {
let detail = Reflect::get(&event, &"detail".into()).unwrap_or(JsValue::UNDEFINED);
let id = field_u64(&detail, "id");
let ok = Reflect::get(&detail, &"ok".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
let error = field_string(&detail, "error").unwrap_or_default();
let result = Reflect::get(&detail, &"result".into()).unwrap_or(JsValue::UNDEFINED);
let slot = PENDING.with(|p| p.borrow_mut().remove(&id));
match slot {
Some(_slot) => {
if ok {
// OneBSpace (OneCAD Simulation): the completion goes on the
// queue the shell's frame drain consumes ([`drain`]).
if let Some(completion) = completion_from_result(&result) {
COMPLETED.with(|c| c.borrow_mut().push(completion));
}
} else {
log::warn!("OneCAD Simulation: the shell refused the request: {error}");
}
ctx.request_repaint();
}
// Writes, exports and imports are acknowledged without a parked
// slot; only their failures are interesting here.
None if !ok => log::warn!("OneCAD Simulation: the shell refused a write/export/import: {error}"),
None => {}
}
});
let Ok(add) = Reflect::get(&window, &"addEventListener".into()) else { return };
let Ok(add) = add.dyn_into::<js_sys::Function>() else { return };
if add.call2(&window, &JsValue::from_str(RESPONSE_EVENT), &listener.into_js_value()).is_ok() {
LISTENER.with(|l| l.set(true));
log::info!("OneCAD Simulation: response listener installed");
} else {
log::warn!("OneCAD Simulation: response listener install FAILED");
}
}
/// Map a `onecadsim:host-response` result onto the queued completion the shell
/// drains (see the WIRE PROTOCOL table above).
fn completion_from_result(result: &JsValue) -> Option<Completion> {
if !result.is_object() {
return None; // the user cancelled the explorer
}
let name = field_string(result, "name");
// An opened record: bytes come as a Uint8Array (preferred) or base64.
if let Ok(bytes) = Reflect::get(result, &"bytes".into())
&& bytes.is_instance_of::<Uint8Array>()
{
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return None };
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes.to_vec()).map(Completion::Open);
}
if let Some(content) = field_string(result, "content")
&& let Some(bytes) = base64_decode(&content)
{
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes).map(Completion::Open);
}
// A Save-as destination: the pending token names the path the engine
// will write to (the glue binds the token to the record).
let token = field_string(result, "token");
if let (Some(token), Some(name)) = (token, name) {
return Some(Completion::SaveAs(format!("{OPENDING}{token}/{}", sanitize(&name))));
}
// Or the glue resolved an explicit emulated path itself.
if let Some(path) = field_string(result, "path")
&& path.starts_with("/odoo/")
{
return Some(Completion::Open(path));
}
None
}
/// Stage a picked record's bytes in the page filesystem at `/odoo/doc/<id>/<name>`.
fn stage_record(record_id: u64, name: Option<&str>, bytes: Vec<u8>) -> Option<String> {
if record_id == 0 {
return None;
}
let path = format!("{ODOC}{record_id}/{}", sanitize(name.unwrap_or("document")));
match fieldcraft_io::fs::insert(Path::new(&path), bytes) {
Ok(()) => Some(path),
Err(e) => {
log::error!("OneCAD Simulation: could not stage the document ({e})");
None
}
}
}
fn field_u64(value: &JsValue, key: &str) -> u64 {
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_f64()).map(|f| f as u64).unwrap_or(0)
}
fn field_string(value: &JsValue, key: &str) -> Option<String> {
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_string())
}
/// File names only key the filesystem: keep them single-segment.
fn sanitize(name: &str) -> String {
let cleaned: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"file".into()
} else {
trimmed.to_string()
}
}
/// Tiny base64 (standard alphabet, padding optional) decoder for bridge
/// payloads that arrive as `content` strings instead of `Uint8Array`s.
fn base64_decode(input: &str) -> Option<Vec<u8>> {
fn value(byte: u8) -> Option<u32> {
match byte {
b'A'..=b'Z' => Some((byte - b'A') as u32),
b'a'..=b'z' => Some((byte - b'a') as u32 + 26),
b'0'..=b'9' => Some((byte - b'0') as u32 + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let clean: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace() && *b != b'=').collect();
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
for chunk in clean.chunks(4) {
if chunk.len() < 2 {
return None;
}
let mut acc: u32 = 0;
for (i, byte) in chunk.iter().enumerate() {
acc |= value(*byte)? << (18 - 6 * i);
}
out.push((acc >> 16) as u8);
if chunk.len() > 2 {
out.push((acc >> 8) as u8);
}
if chunk.len() > 3 {
out.push(acc as u8);
}
}
Some(out)
}
/// Drain hook (tagged call site in `web.rs::hand_out_writes`, right after the
/// recovery-folder branch): consume writes the shell must not turn into device
/// downloads. `/odoo/…` writes go to the bound Documents record (or the pending
/// token's destination on first write); model files elsewhere are write-backs
/// by name; everything else is an export into the "Exports" folder. Returns
/// true when the write was consumed (standalone: always false, upstream
/// behavior untouched).
pub fn handle_write(path: &Path, bytes: &[u8]) -> bool {
if !embedded() {
return false;
}
let path_str = path.to_string_lossy().into_owned();
// Scratch writes (never a record): swallowed, nothing leaves the page.
if path_str.starts_with(OTMP) {
return true;
}
let params = Object::new();
let method = if path_str.starts_with(ODOC) || path_str.starts_with(OPENDING) {
let _ = Reflect::set(&params, &"path".into(), &JsValue::from_str(&path_str));
WRITE
} else if is_model_path(&path_str) {
// Model files written outside the emulated tree (New model saved to the
// page's folder): the bridge resolves the record by name or creates one.
let _ = Reflect::set(&params, &"path".into(), &JsValue::from_str(&path_str));
WRITE
} else {
// Decks, reports, results: an export into the "Exports" folder.
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "export".into());
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(&name));
SAVE_EXPORT
};
let _ = Reflect::set(&params, &"bytes".into(), &Uint8Array::from(bytes).into());
if !dispatch_event(method, next_id(), &params) {
log::error!("OneCAD Simulation: the Odoo bridge is unavailable; \"{path_str}\" stays in the page (the Documents copy was not updated)");
}
// Consumed either way: the air gap wins even if the bridge module is
// gone — bytes never reach the local device from the shell (a copy
// survives in the page filesystem and the localStorage autosave until
// the save succeeds).
true
}
/// `.fieldcraft`/`.solidcraft`/`.3mf` are the model-ish types the shell writes
/// back to Documents; everything else counts as an export.
fn is_model_path(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ["fieldcraft", "solidcraft", "3mf"].iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
}
/// Mirror hook (tagged call site in `web.rs::open_arrived`): CAE assets brought
/// in from the computer get a copy in the Documents "Assets" folder (family
/// storage rule). Deduplicated per (name, size) this session; the JS side
/// additionally dedupes by signature across reloads.
pub fn mirror_import(name: &str, bytes: Option<&[u8]>) {
if !embedded() {
return;
}
let Some(bytes) = bytes else { return };
if !is_asset_name(name) {
return;
}
if !MIRRORED.with(|m| m.borrow_mut().insert((name.to_string(), bytes.len()))) {
return;
}
let params = Object::new();
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
let _ = Reflect::set(&params, &"bytes".into(), &Uint8Array::from(bytes).into());
if !dispatch_event(IMPORT, next_id(), &params) {
log::warn!("OneCAD Simulation: could not mirror \"{name}\" into the Assets folder (bridge not ready)");
}
}
fn is_asset_name(name: &str) -> bool {
Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ASSET_EXTS.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
}
/// The shell's per-frame drain (tagged call site in `web.rs::Shell::logic`):
/// completes queued picks and answers the glue's API requests.
pub fn drain(app: &mut ModelApp) {
let work: Vec<Completion> = COMPLETED.with(|c| std::mem::take(&mut *c.borrow_mut()));
if !work.is_empty() {
log::info!("OneCAD Simulation: drain {} queued item(s)", work.len());
}
for completion in work {
match completion {
Completion::Open(path) => app.open_path(&path),
Completion::SaveAs(path) => {
if let Err(e) = app.session.execute("file.save_as", &serde_json::json!({"path": path})) {
log::warn!("OneCAD Simulation: the save-as to \"{path}\" failed: {e}");
}
}
Completion::Bytes(id) => match app.session.file_bytes(None) {
Ok(bytes) => complete_bytes(id, &bytes),
Err(e) => BYTES_WAITING.with(|b| {
if let Some(reject) = b.borrow_mut().remove(&id) {
let _ = reject.call1(&JsValue::UNDEFINED, &JsValue::from_str(&format!("{e}")));
}
}),
},
Completion::Info(id) => {
let name = app
.session
.path
.as_deref()
.map(|p| sanitize(p.rsplit('/').next().unwrap_or(p)))
.filter(|n| !n.is_empty());
complete_info(id, name);
}
}
}
}
fn complete_bytes(id: u64, bytes: &[u8]) {
if let Some(resolve) = BYTES_WAITING.with(|b| b.borrow_mut().remove(&id)) {
let _ = resolve.call1(&JsValue::UNDEFINED, &Uint8Array::from(bytes).into());
}
}
fn complete_info(id: u64, name: Option<String>) {
if let Some(resolve) = INFO_WAITING.with(|b| b.borrow_mut().remove(&id)) {
let info = Object::new();
let _ = Reflect::set(&info, &"name".into(), &name.as_deref().map(JsValue::from_str).unwrap_or(JsValue::NULL));
let _ = resolve.call1(&JsValue::UNDEFINED, &info.into());
}
}
/// Install the JS→Rust seams once:
///
/// - `window.onecadsimOdoo = { bootFile }` — the staging seam for deep links
/// (`?document=<id>`): the glue inserts the record's bytes into the page
/// filesystem, then opens the returned path via `window.onecadsim.request`.
/// - `window.onecadsim = { request }` — the promise-based API the glue drives
/// (the OneCAD fork's `api.rs` equivalent, kept minimal).
fn install_seams(ctx: egui::Context) {
install_boot_seam();
install_api(ctx);
}
fn install_boot_seam() {
if BOOT.with(Cell::get) {
return;
}
let Some(window) = web_sys::window() else { return };
let boot_file = Closure::<dyn Fn(JsValue, JsValue, JsValue) -> JsValue>::new(
|record_id: JsValue, name: JsValue, bytes: JsValue| {
let id = record_id.as_f64().map(|f| f as u64).unwrap_or(0);
if id == 0 {
return JsValue::UNDEFINED;
}
let name = name.as_string().unwrap_or_else(|| "document".into());
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return JsValue::UNDEFINED };
match stage_record(id, Some(&name), bytes.to_vec()) {
Some(path) => JsValue::from_str(&path),
None => JsValue::UNDEFINED,
}
},
);
let glue = Object::new();
let _ = Reflect::set(&glue, &"bootFile".into(), &boot_file.into_js_value());
if Reflect::set(&window, &"onecadsimOdoo".into(), &glue).is_ok() {
BOOT.with(|b| b.set(true));
}
}
fn install_api(ctx: egui::Context) {
if API.with(Cell::get) {
return;
}
let Some(window) = web_sys::window() else { return };
let request = Closure::<dyn Fn(JsValue, JsValue) -> JsValue>::new(move |method: JsValue, params: JsValue| {
let method = method.as_string().unwrap_or_default();
let ctx = ctx.clone();
js_sys::Promise::new(&mut move |resolve, reject| match method.as_str() {
// Open the staged deep-link path on the shell's next frame.
"ui.file" => {
let path = Reflect::get(&params, &"path".into()).ok().and_then(|v| v.as_string());
match path {
Some(path) if path.starts_with("/odoo/") => {
COMPLETED.with(|c| c.borrow_mut().push(Completion::Open(path)));
ctx.request_repaint();
let _ = resolve.call1(&JsValue::UNDEFINED, &JsValue::NULL);
}
_ => {
let _ = reject.call1(&JsValue::UNDEFINED, &JsValue::from_str("ui.file: an /odoo/ path is required"));
}
}
}
// The current model's bytes, resolved from the shell's frame drain.
"odoo.document.bytes" => {
let id = next_id();
BYTES_WAITING.with(|b| b.borrow_mut().insert(id, resolve));
COMPLETED.with(|c| c.borrow_mut().push(Completion::Bytes(id)));
ctx.request_repaint();
}
// The current model's file name (best effort), same drain.
"odoo.document.info" => {
let id = next_id();
INFO_WAITING.with(|b| b.borrow_mut().insert(id, resolve));
COMPLETED.with(|c| c.borrow_mut().push(Completion::Info(id)));
ctx.request_repaint();
}
other => {
let _ = reject.call1(&JsValue::UNDEFINED, &JsValue::from_str(format!("onecadsim: unknown method \"{other}\"").as_str()));
}
})
.into()
});
let api = Object::new();
let _ = Reflect::set(&api, &"request".into(), &request.into_js_value());
if Reflect::set(&window, &"onecadsim".into(), &api).is_ok() {
API.with(|a| a.set(true));
}
}
+3 -13
View File
@@ -86,10 +86,7 @@ pub fn start() {
web_options(),
Box::new(|cc| {
let inbox = Inbox::default();
let services = services(inbox.clone(), cc.egui_ctx.clone());
// OneBSpace (OneCAD Simulation): install odoo services on wasm
let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());
let mut app = ModelApp::new(Session::default()).with_services(services);
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
// Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md).
if let Some(host) = crate::usage::host() {
app = app.with_usage(host);
@@ -121,7 +118,7 @@ pub fn start() {
el.remove();
}
}
Err(e) => failed(&format!("OneCAD Simulation could not start: {}", js_err(e))),
Err(e) => failed(&format!("FieldCraft could not start: {}", js_err(e))),
}
});
}
@@ -146,7 +143,7 @@ fn failed(why: &str) {
// Best effort: the console has the message too.
let _ = el.class_list().add_1("failed");
for line in
[why, "OneCAD Simulation needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
[why, "FieldCraft needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
{
if let Ok(p) = document.create_element("p") {
p.set_text_content(Some(line));
@@ -230,8 +227,6 @@ impl Shell {
let mut paths = Vec::with_capacity(batch.len());
for (name, read) in batch {
let path = page_path(&name);
// OneBSpace (OneCAD Simulation): mirror computer imports into the Documents "Assets" folder
crate::services_odoo::mirror_import(&name, read.as_deref().ok());
match read.and_then(|bytes| fieldcraft_io::fs::insert(Path::new(&path), bytes).map_err(|e| e.to_string())) {
Ok(()) => paths.push(path),
Err(e) => self.app.set_status(format!("{name}: {e}"), true),
@@ -262,9 +257,6 @@ impl Shell {
}
Err(e) => log::warn!("autosave: {e}"),
}
} // OneBSpace (OneCAD Simulation): the shell air-gaps every other write through the Documents bridge
else if crate::services_odoo::handle_write(&path, &bytes) {
continue;
} else if let Err(e) = download(&path, &bytes) {
self.app.set_status(format!("{}: the download did not start ({e})", path.display()), true);
}
@@ -287,8 +279,6 @@ impl Shell {
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.open_arrived();
// OneBSpace (OneCAD Simulation): complete Documents picks (open/save-as/serialize)
crate::services_odoo::drain(&mut self.app);
self.app.logic(ctx);
self.autosave(ctx);
if self.app.now - self.sent_at >= crate::usage::INTERVAL {
+2 -5
View File
@@ -248,9 +248,7 @@ fn main() -> eframe::Result {
crash::phase("window");
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
// OneBSpace (OneCAD Simulation): the window title names the fork (the APP_ID, which
// the packaged .desktop entries and the bundle id match, stays upstream).
.with_title("OneCAD Simulation")
.with_title("FieldCraft")
.with_app_id(APP_ID)
.with_icon(app_icon())
.with_inner_size([1400.0, 900.0])
@@ -267,8 +265,7 @@ fn main() -> eframe::Result {
..Default::default()
};
eframe::run_native(
// OneBSpace (OneCAD Simulation): the app name in the window title.
"OneCAD Simulation",
"FieldCraft",
options,
Box::new(move |cc| {
crash::phase("app setup");
+4 -9
View File
@@ -21,9 +21,7 @@ pub const ISSUE_URL: &str = "https://github.com/tangency-labs/fieldcraft/issues/
/// The longest link a draft opens: GitHub refuses new-issue links much past 8 KB.
pub const MAX_URL: usize = 7000;
/// The session file a draft attaches.
// OneBSpace (OneCAD Simulation): the attachment name carries the fork's mark; the internal API
// name (`SESSION_FILE`) stays upstream.
pub const SESSION_FILE: &str = "onecadsim-session.json";
pub const SESSION_FILE: &str = "fieldcraft-session.json";
/// The model file a draft attaches.
pub const MODEL_FILE: &str = "model.fieldcraft";
/// Backtrace frames a crash's draft quotes.
@@ -160,9 +158,8 @@ pub fn draft(input: &Input, version: &str) -> Draft {
let crash = input.crash.as_ref();
let mut title = input.title.trim().to_string();
if title.is_empty() {
// OneBSpace (OneCAD Simulation): the fork's name in the drafted title.
title = match crash.and_then(|c| c.get("kind")).and_then(Value::as_str) {
Some("unclean_exit") => "OneCAD Simulation closed unexpectedly".to_string(),
Some("unclean_exit") => "FieldCraft closed unexpectedly".to_string(),
Some(_) => "Internal error".to_string(),
None => match input.observed.trim().lines().next().filter(|l| !l.is_empty()) {
Some(line) => format!("Bug: {}", line.chars().take(100).collect::<String>()),
@@ -227,16 +224,14 @@ pub fn draft(input: &Input, version: &str) -> Draft {
} else {
let text = serde_json::to_string_pretty(script).unwrap_or_default();
evidence.push_str(&format!(
// OneBSpace (OneCAD Simulation): the fork's name in the evidence text.
"\nThe session is too long for this form: OneCAD Simulation saved it as `{SESSION_FILE}` and copied it to the clipboard. Attach the file here (drag it in) or paste it.\n"
"\nThe session is too long for this form: FieldCraft saved it as `{SESSION_FILE}` and copied it to the clipboard. Attach the file here (drag it in) or paste it.\n"
));
attachments.insert(0, Attachment { name: SESSION_FILE.into(), bytes: text.clone().into_bytes(), what: "the session's commands".into() });
clipboard = Some(text);
}
}
if model.is_some() {
// OneBSpace (OneCAD Simulation): the fork's name in the evidence text.
evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder OneCAD Simulation saved it to).\n"));
evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder FieldCraft saved it to).\n"));
}
if url.is_empty() || model.is_some() {
let mut f = fields(evidence.clone());
+1 -3
View File
@@ -43,9 +43,7 @@ pub const CRASH_REPORTS_DELETE: CommandSpec = CommandSpec {
pub const BUG_REPORT: CommandSpec = CommandSpec {
id: "diagnostics.bug_report",
label: "Report a Bug",
// OneBSpace (OneCAD Simulation): the draft stays local — no GitHub issue link is opened;
// the form's Copy report puts the report on the clipboard and saves attachments to a folder.
params: r#"{"title"?: "…", "observed"?: "what happened", "crash"?: "<crash report id>", "share_session"?: false, "include_model"?: false} — drafts a bug report that stays in the workspace: the version, the OS, what happened and, for a crash, where, and the files to attach. Only with share_session does it carry the model's commands, and only with include_model as well the model file. Nothing is sent: the form's Copy report copies the report to the clipboard and saves the attachments to a local folder"#,
params: r#"{"title"?: "…", "observed"?: "what happened", "crash"?: "<crash report id>", "share_session"?: false, "include_model"?: false} — drafts a GitHub bug report: the issue link (the bug form filled with the version, the OS, what happened and, for a crash, where), and the files to attach. Only with share_session does it carry the model's commands, and only with include_model as well the model file. Nothing is sent: the desktop app's Help › Report a Bug shows the draft and opens the link when the user asks"#,
form: Some(super::forms::BUG_REPORT),
kind: CommandKind::Session,
requires: &[],
+1 -2
View File
@@ -15,8 +15,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const CHECK: CommandSpec = CommandSpec {
id: "app.update.check",
label: "Check for Updates",
// OneBSpace (OneCAD Simulation): the fork's name in the palette help text (desktop-only).
params: r#"{} — asks GitHub Releases for the latest published OneCAD Simulation release and compares it with this version: current, latest, newer, url (its page and release notes), published, signed (whether it can be verified and installed from the app) and asset (the file this install would download; null when it cannot update itself). Reports only: nothing is downloaded or installed. Desktop app and fieldcraft-cli only"#,
params: r#"{} — asks GitHub Releases for the latest published FieldCraft release and compares it with this version: current, latest, newer, url (its page and release notes), published, signed (whether it can be verified and installed from the app) and asset (the file this install would download; null when it cannot update itself). Reports only: nothing is downloaded or installed. Desktop app and fieldcraft-cli only"#,
form: Some(&[]),
kind: CommandKind::Session,
requires: &[],
+3 -7
View File
@@ -493,8 +493,7 @@ fn hook(info: &std::panic::PanicHookInfo<'_>) -> bool {
// Outside any command: what the journal knows has to do.
let extra = from_journal(read_live_journal(state), None);
if let Some(id) = write_report(state, "panic", Some(&p), extra) {
// OneBSpace (OneCAD Simulation): the fork's name on the console.
eprintln!("OneCAD Simulation: internal error; crash report {}", state.dir.join(&id).display());
eprintln!("FieldCraft: internal error; crash report {}", state.dir.join(&id).display());
if let Ok(mut j) = state.journal.try_lock() {
j.record(&json!({ "reported": id }));
}
@@ -891,9 +890,7 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
let p = match &native {
Some(n) => native_panic(n, &was, &when, &during, &what),
None => Panic {
// OneBSpace (OneCAD Simulation): the fork's name in the crash message (shown in the
// crash question and in the report the user copies).
message: format!("OneCAD Simulation {was} (started {when}) ended without a clean exit {during}"),
message: format!("FieldCraft {was} (started {when}) ended without a clean exit {during}"),
location: match (&starting, &doing) {
(Some((p, _)), _) => format!("unclean exit during startup: {p}"),
(None, c) => format!("unclean exit in {}", c.as_deref().unwrap_or("(between commands)")),
@@ -961,8 +958,7 @@ fn native_panic(native: &Value, was: &str, when: &str, during: &str, what: &str)
backtrace.push(format!("on thread {t}"));
}
Panic {
// OneBSpace (OneCAD Simulation): the fork's name in the native-crash message.
message: format!("OneCAD Simulation {was} (started {when}) crashed with {name} ({code}) in {at} {during}"),
message: format!("FieldCraft {was} (started {when}) crashed with {name} ({code}) in {at} {during}"),
location: format!("native exception in {at}"),
thread: native.get("thread").and_then(Value::as_u64).map_or_else(|| "(unknown)".into(), |t| format!("native thread {t}")),
backtrace,
+96
View File
@@ -0,0 +1,96 @@
//! Physical dimensions as integer exponents of the SI base dimensions.
use std::fmt;
/// A physical dimension: the exponents of length L, mass M, time T, temperature Θ and electric
/// current I. Dimensionless quantities (Poisson's ratio, strain, angles) have every exponent 0.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Dimension {
pub length: i8,
pub mass: i8,
pub time: i8,
pub temperature: i8,
pub current: i8,
}
impl Dimension {
pub const NONE: Dimension = Dimension::new(0, 0, 0, 0);
pub const LENGTH: Dimension = Dimension::new(1, 0, 0, 0);
pub const MASS: Dimension = Dimension::new(0, 1, 0, 0);
pub const TIME: Dimension = Dimension::new(0, 0, 1, 0);
pub const TEMPERATURE: Dimension = Dimension::new(0, 0, 0, 1);
pub const CURRENT: Dimension = Dimension { current: 1, ..Dimension::NONE };
/// L^length M^mass T^time Θ^temperature (no current: FieldCraft has no electrical quantities yet).
pub const fn new(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension {
Dimension { length, mass, time, temperature, current: 0 }
}
fn exponents(self) -> [i8; 5] {
[self.length, self.mass, self.time, self.temperature, self.current]
}
fn from_exponents([length, mass, time, temperature, current]: [i8; 5]) -> Dimension {
Dimension { length, mass, time, temperature, current }
}
fn zip(self, other: Dimension, f: impl Fn(i8, i8) -> Option<i8>) -> Option<Dimension> {
let (a, b) = (self.exponents(), other.exponents());
let mut out = [0i8; 5];
for ((o, x), y) in out.iter_mut().zip(a).zip(b) {
*o = f(x, y)?;
}
Some(Dimension::from_exponents(out))
}
/// The dimension of a product, or `None` if an exponent overflows.
pub fn checked_mul(self, other: Dimension) -> Option<Dimension> {
self.zip(other, i8::checked_add)
}
/// The dimension of a quotient, or `None` if an exponent overflows.
pub fn checked_div(self, other: Dimension) -> Option<Dimension> {
self.zip(other, i8::checked_sub)
}
/// The dimension raised to an integer power, or `None` if an exponent overflows.
pub fn checked_pow(self, power: i8) -> Option<Dimension> {
self.zip(Dimension::NONE, |x, _| x.checked_mul(power))
}
}
impl fmt::Display for Dimension {
/// `L M T^-2` style; `1` for dimensionless.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut first = true;
for (symbol, e) in ["L", "M", "T", "Θ", "I"].into_iter().zip(self.exponents()) {
if e == 0 {
continue;
}
if !first {
f.write_str(" ")?;
}
first = false;
if e == 1 { write!(f, "{symbol}")? } else { write!(f, "{symbol}^{e}")? }
}
if first { f.write_str("1") } else { Ok(()) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arithmetic_and_display() {
let force = Dimension::new(1, 1, -2, 0);
let area = Dimension::LENGTH.checked_pow(2).unwrap();
let pressure = force.checked_div(area).unwrap();
assert_eq!(pressure, Dimension::new(-1, 1, -2, 0));
assert_eq!(pressure.to_string(), "L^-1 M T^-2");
assert_eq!(Dimension::NONE.to_string(), "1");
assert_eq!(pressure.checked_mul(area), Some(force));
assert_eq!(Dimension::new(100, 0, 0, 0).checked_pow(2), None);
assert_eq!(Dimension::new(127, 0, 0, 0).checked_mul(Dimension::LENGTH), None);
}
}
+151
View File
@@ -0,0 +1,151 @@
//! Display systems: which unit each quantity is shown and typed in. Choosing one never changes a
//! stored value.
use serde::{Deserialize, Serialize};
use super::{Quantity, Unit, UnitError};
/// A consistent set of display units.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Preset {
/// Strict SI: m, kg, s, N, Pa, K.
#[serde(rename = "SI")]
Si,
/// SI with temperatures in °C and angles in degrees.
#[serde(rename = "m-kg-s")]
MKgS,
/// Millimetre, tonne, second, newton, megapascal, °C: what most structural CAE users expect.
#[default]
#[serde(rename = "mm-t-s-N-MPa")]
MmTonneS,
/// Inch, lbf·s²/in, second, pound-force, psi, °F.
#[serde(rename = "in-lbf-s-psi")]
InLbfS,
}
impl Preset {
pub const ALL: [Preset; 4] = [Preset::Si, Preset::MKgS, Preset::MmTonneS, Preset::InLbfS];
pub fn label(self) -> &'static str {
match self {
Preset::Si => "SI",
Preset::MKgS => "m-kg-s",
Preset::MmTonneS => "mm-t-s-N-MPa",
Preset::InLbfS => "in-lbf-s-psi",
}
}
pub fn from_label(label: &str) -> Option<Preset> {
Preset::ALL.into_iter().find(|p| p.label() == label)
}
/// The unit this preset shows `q` in.
pub fn symbol(self, q: Quantity) -> &'static str {
use Quantity as Q;
match self {
Preset::Si => q.si_symbol(),
Preset::MKgS => match q {
Q::Temperature => "°C",
Q::Angle => "deg",
_ => q.si_symbol(),
},
Preset::MmTonneS => match q {
Q::Dimensionless => "1",
Q::Angle => "deg",
Q::Length => "mm",
Q::Area => "mm²",
Q::Volume => "mm³",
Q::SecondMomentOfArea => "mm⁴",
Q::Mass => "t",
Q::Density => "t/mm³",
Q::Time => "s",
Q::Frequency => "Hz",
Q::Velocity => "mm/s",
Q::Acceleration => "mm/s²",
Q::Force => "N",
Q::Moment => "N·mm",
Q::Pressure => "MPa",
Q::Stiffness => "N/mm",
Q::Damping => "N·s/mm",
Q::Energy => "mJ",
Q::Power => "mW",
Q::DynamicViscosity => "MPa·s",
Q::KinematicViscosity => "mm²/s",
Q::Temperature => "°C",
Q::TemperatureDifference => "K",
Q::ThermalExpansion => "K⁻¹",
Q::ThermalConductivity => "mW/(mm·K)",
Q::SpecificHeat => "mJ/(t·K)",
Q::HeatTransferCoefficient => "mW/(mm²·K)",
Q::HeatFlux => "mW/mm²",
Q::HeatGeneration => "mW/mm³",
},
Preset::InLbfS => match q {
Q::Dimensionless => "1",
Q::Angle => "deg",
Q::Length => "in",
Q::Area => "in²",
Q::Volume => "in³",
Q::SecondMomentOfArea => "in⁴",
Q::Mass => "lbf·s²/in",
Q::Density => "lbf·s²/in⁴",
Q::Time => "s",
Q::Frequency => "Hz",
Q::Velocity => "in/s",
Q::Acceleration => "in/s²",
Q::Force => "lbf",
Q::Moment => "lbf·in",
Q::Pressure => "psi",
Q::Stiffness => "lbf/in",
Q::Damping => "lbf·s/in",
Q::Energy => "in·lbf",
Q::Power => "in·lbf/s",
Q::DynamicViscosity => "psi·s",
Q::KinematicViscosity => "in²/s",
Q::Temperature => "°F",
Q::TemperatureDifference => "°F",
Q::ThermalExpansion => "°F⁻¹",
Q::ThermalConductivity => "lbf/(s·°F)",
Q::SpecificHeat => "in²/(s²·°F)",
Q::HeatTransferCoefficient => "lbf/(in·s·°F)",
Q::HeatFlux => "lbf/(in·s)",
Q::HeatGeneration => "lbf/(in²·s)",
},
}
}
}
/// The units values are shown and typed in: a preset with optional per-quantity overrides (say
/// mm-t-s-N-MPa with energies in J).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DisplaySystem {
preset: Preset,
overrides: Vec<(Quantity, Unit)>,
}
impl DisplaySystem {
pub fn new(preset: Preset) -> DisplaySystem {
DisplaySystem { preset, overrides: Vec::new() }
}
pub fn preset(&self) -> Preset {
self.preset
}
/// Shows `q` in `unit` instead of the preset's unit; refused when the dimension differs.
pub fn with_override(mut self, q: Quantity, unit: &str) -> Result<DisplaySystem, UnitError> {
let unit = Unit::parse(unit)?;
q.express(0.0, &unit)?;
self.overrides.retain(|(o, _)| *o != q);
self.overrides.push((q, unit));
Ok(self)
}
/// The unit `q` is shown and typed in.
pub fn unit(&self, q: Quantity) -> Result<Unit, UnitError> {
match self.overrides.iter().find(|(o, _)| *o == q) {
Some((_, u)) => Ok(u.clone()),
None => Unit::parse(self.preset.symbol(q)),
}
}
}
@@ -1,9 +1,52 @@
//! The unit system label.
//! Units: the unit system label a model carries today, and the SI unit table every conversion
//! goes through.
//!
//! The model never converts: numbers are stored as entered. The label records which consistent
//! system they are in, so the check can warn when a magnitude is implausible for it (a steel
//! modulus of 210 000 in a metre–pascal model, a density of 7850 in a millimetre–tonne one), and
//! so writers can say which system a deck is in.
//! **The table** ([`Dimension`], [`Unit`], [`Quantity`], [`DisplaySystem`]) is the one place
//! FieldCraft writes down a conversion factor. Each [`Quantity`] has a dimension and an SI unit;
//! a [`Unit`] parses expressions such as `MPa`, `t/mm³` or `W/(m·K)` to a dimension and a factor
//! to SI; [`Quantity::parse`] reads `"210 GPa"` or `"20 °C"` with a dimension check, and
//! [`Quantity::format`] shows an SI value in a [`DisplaySystem`]. Absolute temperatures
//! ([`Quantity::Temperature`]) take the °C/°F offset; temperature differences and every compound
//! unit never do. Issue #579 moves model storage to SI on top of this table; until then the
//! model still stores numbers as entered.
//!
//! **The label** ([`UnitSystem`]): the model never converts yet: numbers are stored as entered.
//! The label records which consistent system they are in, so the check can warn when a magnitude
//! is implausible for it (a steel modulus of 210 000 in a metre–pascal model, a density of 7850
//! in a millimetre–tonne one), and so writers can say which system a deck is in.
mod dimension;
mod display;
mod quantity;
mod unit;
pub use dimension::Dimension;
pub use display::{DisplaySystem, Preset};
pub use quantity::{Quantity, ValueInput, WithUnit, format_number};
pub use unit::Unit;
/// Why a unit or a value with a unit was refused.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum UnitError {
#[error("no value or unit given")]
Empty,
#[error("unit text is too long")]
TooLong,
#[error("`{0}` is not a finite number")]
NotFinite(String),
#[error("unknown unit `{0}`")]
UnknownUnit(String),
#[error("cannot read `{0}` as a value with a unit (for example `210 GPa`, `t/mm³`, `W/(m·K)`)")]
Syntax(String),
#[error("`{0}` is out of range")]
OutOfRange(String),
#[error("`{0}` is below absolute zero")]
BelowAbsoluteZero(String),
#[error("`{unit}` is not a unit of {quantity} (angles take rad, deg or °; ratios take 1 or %)")]
WrongKind { unit: String, quantity: &'static str },
#[error("`{unit}` is {found}, but a {quantity} is {expected}")]
WrongDimension { unit: String, found: Dimension, quantity: &'static str, expected: Dimension },
}
use serde::{Deserialize, Serialize};
+246
View File
@@ -0,0 +1,246 @@
//! The kinds of physical quantity FieldCraft stores, and parsing and formatting of values with
//! units ("210 GPa", `{"value": 20, "unit": "°C"}`) against them.
use serde::{Deserialize, Serialize};
use super::unit::MAX_TEXT;
use super::{Dimension, DisplaySystem, Unit, UnitError};
macro_rules! quantities {
($($variant:ident = $name:literal, $dim:expr, $si:literal;)*) => {
/// A kind of physical quantity. Each has one dimension and one SI unit, which is how it is
/// stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Quantity {
$($variant,)*
}
impl Quantity {
pub const ALL: &[Quantity] = &[$(Quantity::$variant,)*];
/// The name used in commands and files (`thermal_conductivity`, …).
pub fn name(self) -> &'static str {
match self { $(Quantity::$variant => $name,)* }
}
pub fn dimension(self) -> Dimension {
match self { $(Quantity::$variant => $dim,)* }
}
/// The SI unit the quantity is stored in.
pub fn si_symbol(self) -> &'static str {
match self { $(Quantity::$variant => $si,)* }
}
}
};
}
const fn d(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension {
Dimension::new(length, mass, time, temperature)
}
quantities! {
Dimensionless = "dimensionless", d(0, 0, 0, 0), "1";
Angle = "angle", d(0, 0, 0, 0), "rad";
Length = "length", d(1, 0, 0, 0), "m";
Area = "area", d(2, 0, 0, 0), "m²";
Volume = "volume", d(3, 0, 0, 0), "m³";
SecondMomentOfArea = "second_moment_of_area", d(4, 0, 0, 0), "m⁴";
Mass = "mass", d(0, 1, 0, 0), "kg";
Density = "density", d(-3, 1, 0, 0), "kg/m³";
Time = "time", d(0, 0, 1, 0), "s";
Frequency = "frequency", d(0, 0, -1, 0), "Hz";
Velocity = "velocity", d(1, 0, -1, 0), "m/s";
Acceleration = "acceleration", d(1, 0, -2, 0), "m/s²";
Force = "force", d(1, 1, -2, 0), "N";
Moment = "moment", d(2, 1, -2, 0), "N·m";
Pressure = "pressure", d(-1, 1, -2, 0), "Pa";
Stiffness = "stiffness", d(0, 1, -2, 0), "N/m";
Damping = "damping", d(0, 1, -1, 0), "N·s/m";
Energy = "energy", d(2, 1, -2, 0), "J";
Power = "power", d(2, 1, -3, 0), "W";
DynamicViscosity = "dynamic_viscosity", d(-1, 1, -1, 0), "Pa·s";
KinematicViscosity = "kinematic_viscosity", d(2, 0, -1, 0), "m²/s";
Temperature = "temperature", d(0, 0, 0, 1), "K";
TemperatureDifference = "temperature_difference", d(0, 0, 0, 1), "K";
ThermalExpansion = "thermal_expansion", d(0, 0, 0, -1), "K⁻¹";
ThermalConductivity = "thermal_conductivity", d(1, 1, -3, -1), "W/(m·K)";
SpecificHeat = "specific_heat", d(2, 0, -2, -1), "J/(kg·K)";
HeatTransferCoefficient = "heat_transfer_coefficient", d(0, 1, -3, -1), "W/(m²·K)";
HeatFlux = "heat_flux", d(0, 1, -3, 0), "W/m²";
HeatGeneration = "heat_generation", d(-1, 1, -3, 0), "W/m³";
}
impl Quantity {
pub fn from_name(name: &str) -> Option<Quantity> {
Quantity::ALL.iter().copied().find(|q| q.name() == name)
}
/// Whether the unit's zero offset applies (°C and °F as absolute temperatures).
fn absolute(self) -> bool {
self == Quantity::Temperature
}
/// Converts `value` in `unit` to this quantity's SI value, checking the dimension and the
/// result.
pub fn to_si(self, value: f64, unit: &Unit) -> Result<f64, UnitError> {
if !value.is_finite() {
return Err(UnitError::NotFinite(value.to_string()));
}
self.check(unit)?;
let si = unit.to_si(value, self.absolute());
if !si.is_finite() {
return Err(UnitError::OutOfRange(format!("{value} {}", unit.symbol())));
}
if self.absolute() && si < 0.0 {
return Err(UnitError::BelowAbsoluteZero(format!("{value} {}", unit.symbol())));
}
Ok(si)
}
/// Converts an SI value of this quantity to `unit`.
pub fn express(self, si: f64, unit: &Unit) -> Result<f64, UnitError> {
self.check(unit)?;
let v = unit.express(si, self.absolute());
if v.is_finite() { Ok(v) } else { Err(UnitError::NotFinite(v.to_string())) }
}
fn check(self, unit: &Unit) -> Result<(), UnitError> {
if unit.dimension() == self.dimension() {
// Angles and ratios share the empty dimension: only an angle takes rad, deg or °.
if unit.is_angle() == (self == Quantity::Angle) {
Ok(())
} else {
Err(UnitError::WrongKind { unit: unit.symbol().to_string(), quantity: self.name() })
}
} else {
Err(UnitError::WrongDimension {
unit: unit.symbol().to_string(),
found: unit.dimension(),
quantity: self.name(),
expected: self.dimension(),
})
}
}
/// Parses `"210 GPa"`, `"25 mm/s"`, `"-5 °C"` (or a bare `"210"`, meaning `display`'s unit)
/// to the SI value.
pub fn parse(self, text: &str, display: &DisplaySystem) -> Result<f64, UnitError> {
let text = text.trim();
if text.is_empty() {
return Err(UnitError::Empty);
}
if text.len() > MAX_TEXT {
return Err(UnitError::TooLong);
}
let (number, unit) = split_number(text).ok_or_else(|| UnitError::Syntax(text.to_string()))?;
if unit.trim().is_empty() { self.to_si(number, &display.unit(self)?) } else { self.to_si(number, &Unit::parse(unit)?) }
}
/// Formats an SI value in `display`'s unit for this quantity, with the unit: `210000 MPa`.
pub fn format(self, si: f64, display: &DisplaySystem) -> Result<String, UnitError> {
let unit = display.unit(self)?;
let v = self.express(si, &unit)?;
Ok(if unit.symbol() == "1" { format_number(v) } else { format!("{} {}", format_number(v), unit.symbol()) })
}
}
/// The leading number of `text` and the rest. `inf` and `nan` are not numbers here.
fn split_number(text: &str) -> Option<(f64, &str)> {
let end = text.find(|c: char| !(c.is_ascii_digit() || matches!(c, '+' | '-' | '.' | 'e' | 'E'))).unwrap_or(text.len());
// Shrink until the prefix parses: "2e" in "2em" is not an exponent.
(1..=end).rev().find_map(|i| {
let head = text.get(..i)?;
let v: f64 = head.parse().ok()?;
Some((v, text.get(i..)?))
})
}
/// A value with six significant digits and no trailing zeros: `210000`, `7.85e-9`, `0.3`.
pub fn format_number(v: f64) -> String {
if v == 0.0 || !v.is_finite() {
return if v == 0.0 { "0".to_string() } else { v.to_string() };
}
let exp = v.abs().log10().floor();
if (-4.0..7.0).contains(&exp) {
let decimals = (5.0 - exp).max(0.0) as usize;
let s = format!("{v:.decimals$}");
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
} else {
let s = format!("{v:.5e}");
match s.split_once('e') {
Some((mantissa, e)) => {
let m = if mantissa.contains('.') { mantissa.trim_end_matches('0').trim_end_matches('.') } else { mantissa };
format!("{m}e{e}")
}
None => s,
}
}
}
/// A value as commands, scripts and the MCP give it: a bare number (in the display system), a
/// string with its unit (`"210 GPa"`), or `{"value": 210, "unit": "GPa"}`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValueInput {
Number(f64),
Text(String),
WithUnit(WithUnit),
}
/// `{"value": 210, "unit": "GPa"}`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WithUnit {
pub value: f64,
pub unit: String,
}
impl ValueInput {
/// The SI value of this input as quantity `q`; a bare number is in `display`'s unit.
pub fn to_si(&self, q: Quantity, display: &DisplaySystem) -> Result<f64, UnitError> {
match self {
ValueInput::Number(v) => q.to_si(*v, &display.unit(q)?),
ValueInput::Text(t) => q.parse(t, display),
ValueInput::WithUnit(w) => q.to_si(w.value, &Unit::parse(&w.unit)?),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_si_symbol_parses_to_its_dimension_with_factor_one() {
for &q in Quantity::ALL {
let u = Unit::parse(q.si_symbol()).unwrap_or_else(|e| panic!("{q:?}: {e}"));
assert_eq!(u.dimension(), q.dimension(), "{q:?}");
assert!((u.factor() - 1.0).abs() < 1e-15, "{q:?}");
assert_eq!(Quantity::from_name(q.name()), Some(q));
}
}
#[test]
fn numbers_split_from_units() {
assert_eq!(split_number("210 GPa"), Some((210.0, " GPa")));
assert_eq!(split_number("2.1e11Pa"), Some((2.1e11, "Pa")));
assert_eq!(split_number("-5 °C"), Some((-5.0, " °C")));
assert_eq!(split_number("3e"), Some((3.0, "e")));
assert_eq!(split_number("inf Pa"), None);
assert_eq!(split_number("NaN"), None);
assert_eq!(split_number("GPa"), None);
}
#[test]
fn numbers_format_with_six_significant_digits() {
assert_eq!(format_number(210000.0), "210000");
assert_eq!(format_number(7.85e-9), "7.85e-9");
assert_eq!(format_number(0.3), "0.3");
assert_eq!(format_number(2.1e11), "2.1e11");
assert_eq!(format_number(-20.0), "-20");
assert_eq!(format_number(1.0 / 3.0), "0.333333");
assert_eq!(format_number(0.0), "0");
}
}
+420
View File
@@ -0,0 +1,420 @@
//! Named units and unit expressions (`MPa`, `t/mm³`, `W/(m·K)`), each reduced to a dimension and
//! a factor to SI.
//!
//! This table is the only place in FieldCraft where a conversion factor is written down.
use super::{Dimension, UnitError};
/// Exact definitions of the non-metric units (NIST SP 811).
const INCH: f64 = 0.0254;
const FOOT: f64 = 0.3048;
const POUND_MASS: f64 = 0.453_592_37;
const STANDARD_GRAVITY: f64 = 9.806_65;
const POUND_FORCE: f64 = POUND_MASS * STANDARD_GRAVITY;
/// Celsius zero in kelvin.
const CELSIUS_ZERO: f64 = 273.15;
/// One degree Fahrenheit (or Rankine) in kelvin.
const FAHRENHEIT_STEP: f64 = 5.0 / 9.0;
/// Fahrenheit zero in kelvin: 459.67 °R.
const FAHRENHEIT_ZERO: f64 = 459.67 * FAHRENHEIT_STEP;
const L: Dimension = Dimension::LENGTH;
const M: Dimension = Dimension::MASS;
const T: Dimension = Dimension::TIME;
const TH: Dimension = Dimension::TEMPERATURE;
const ONE: Dimension = Dimension::NONE;
const FORCE: Dimension = Dimension::new(1, 1, -2, 0);
const PRESSURE: Dimension = Dimension::new(-1, 1, -2, 0);
const ENERGY: Dimension = Dimension::new(2, 1, -2, 0);
const POWER: Dimension = Dimension::new(2, 1, -3, 0);
const FREQUENCY: Dimension = Dimension::new(0, 0, -1, 0);
/// A named unit: its spellings (the first is the canonical symbol), dimension, the size of one
/// unit in SI, and the SI value of its zero (non-zero only for °C and °F).
struct Atom {
names: &'static [&'static str],
dimension: Dimension,
factor: f64,
offset: f64,
}
const fn atom(names: &'static [&'static str], dimension: Dimension, factor: f64) -> Atom {
Atom { names, dimension, factor, offset: 0.0 }
}
const ATOMS: &[Atom] = &[
// Length.
atom(&["m"], L, 1.0),
atom(&["km"], L, 1e3),
atom(&["cm"], L, 1e-2),
atom(&["mm"], L, 1e-3),
atom(&["µm", "μm", "um"], L, 1e-6),
atom(&["nm"], L, 1e-9),
atom(&["in", "inch"], L, INCH),
atom(&["ft"], L, FOOT),
// Mass.
atom(&["kg"], M, 1.0),
atom(&["g"], M, 1e-3),
atom(&["mg"], M, 1e-6),
atom(&["t", "tonne"], M, 1e3),
atom(&["lb", "lbm"], M, POUND_MASS),
// Time.
atom(&["s"], T, 1.0),
atom(&["ms"], T, 1e-3),
atom(&["µs", "μs", "us"], T, 1e-6),
atom(&["min"], T, 60.0),
atom(&["h"], T, 3600.0),
atom(&["Hz"], FREQUENCY, 1.0),
atom(&["kHz"], FREQUENCY, 1e3),
// Temperature: as an absolute temperature °C and °F carry their offset; inside a compound unit
// (W/(m·°C)) or as a temperature difference they are only a step size.
atom(&["K"], TH, 1.0),
Atom { names: &["°C", "degC", "℃"], dimension: TH, factor: 1.0, offset: CELSIUS_ZERO },
Atom { names: &["°F", "degF", "℉"], dimension: TH, factor: FAHRENHEIT_STEP, offset: FAHRENHEIT_ZERO },
// Force.
atom(&["N"], FORCE, 1.0),
atom(&["mN"], FORCE, 1e-3),
atom(&["kN"], FORCE, 1e3),
atom(&["MN"], FORCE, 1e6),
atom(&["lbf"], FORCE, POUND_FORCE),
atom(&["kip"], FORCE, 1e3 * POUND_FORCE),
// Pressure and stress.
atom(&["Pa"], PRESSURE, 1.0),
atom(&["kPa"], PRESSURE, 1e3),
atom(&["MPa"], PRESSURE, 1e6),
atom(&["GPa"], PRESSURE, 1e9),
atom(&["bar"], PRESSURE, 1e5),
atom(&["psi"], PRESSURE, POUND_FORCE / (INCH * INCH)),
atom(&["ksi"], PRESSURE, 1e3 * POUND_FORCE / (INCH * INCH)),
// Energy and power.
atom(&["J"], ENERGY, 1.0),
atom(&["mJ"], ENERGY, 1e-3),
atom(&["kJ"], ENERGY, 1e3),
atom(&["W"], POWER, 1.0),
atom(&["mW"], POWER, 1e-3),
atom(&["kW"], POWER, 1e3),
// Dimensionless: angles and ratios.
atom(&["rad"], ONE, 1.0),
atom(&["deg", "°"], ONE, std::f64::consts::PI / 180.0),
atom(&["%"], ONE, 1e-2),
];
/// Longest accepted unit or quantity text; longer input is refused unparsed.
pub(super) const MAX_TEXT: usize = 64;
/// Largest accepted exponent in a unit expression (`m^6` is the largest anything needs).
const MAX_POWER: i8 = 6;
/// Deepest accepted parenthesis nesting.
const MAX_DEPTH: usize = 4;
/// A unit, possibly compound, reduced to SI: `value_si = value * factor + offset`.
#[derive(Debug, Clone, PartialEq)]
pub struct Unit {
symbol: String,
dimension: Dimension,
factor: f64,
/// The SI value of the unit's zero: non-zero only for a lone °C or °F.
offset: f64,
/// Whether the unit names an angle (`rad`, `deg`, `°`): only angles accept it.
angle: bool,
}
impl Unit {
/// Parses a unit expression: named units joined by `*`, `·`, a space or `/`, with integer
/// powers (`^2`, `²`, `^-1`, `⁻¹`, or trailing digits as in `mm2`) and parentheses; a leading
/// `1` is a numerator (`1/K`). `1` alone is the unit of a dimensionless quantity. `.` is not a
/// multiplication sign (it reads as a decimal point).
pub fn parse(text: &str) -> Result<Unit, UnitError> {
let text = text.trim();
if text.is_empty() {
return Err(UnitError::Empty);
}
if text.len() > MAX_TEXT {
return Err(UnitError::TooLong);
}
if text == "1" {
return Ok(Unit { symbol: text.to_string(), dimension: Dimension::NONE, factor: 1.0, offset: 0.0, angle: false });
}
let mut p = Parser { chars: text.chars().collect(), pos: 0, text, angle: false };
let (dimension, factor, atoms, offset) = p.expression(0)?;
if p.pos != p.chars.len() {
return Err(p.syntax());
}
// An offset only means something for a lone temperature unit.
let offset = if atoms == 1 { offset } else { 0.0 };
if !(factor.is_finite() && factor > 0.0) {
return Err(UnitError::OutOfRange(text.to_string()));
}
Ok(Unit { symbol: text.to_string(), dimension, factor, offset, angle: p.angle })
}
pub fn symbol(&self) -> &str {
&self.symbol
}
pub fn dimension(&self) -> Dimension {
self.dimension
}
/// The size of one unit in SI (for °C and °F, the size of one degree).
pub fn factor(&self) -> f64 {
self.factor
}
/// Whether the unit names an angle (`rad`, `deg`, `°`).
pub fn is_angle(&self) -> bool {
self.angle
}
/// Converts a value in this unit to SI. `absolute` applies the zero offset (absolute
/// temperatures); temperature differences and every other quantity pass `false`.
pub(super) fn to_si(&self, value: f64, absolute: bool) -> f64 {
value * self.factor + if absolute { self.offset } else { 0.0 }
}
/// Converts an SI value to this unit; the inverse of [`Unit::to_si`].
pub(super) fn express(&self, si: f64, absolute: bool) -> f64 {
(si - if absolute { self.offset } else { 0.0 }) / self.factor
}
}
struct Parser<'a> {
chars: Vec<char>,
pos: usize,
text: &'a str,
angle: bool,
}
/// Dimension, factor, number of named units, offset of the last named unit.
type Parsed = (Dimension, f64, usize, f64);
impl Parser<'_> {
fn syntax(&self) -> UnitError {
UnitError::Syntax(self.text.to_string())
}
fn peek(&self) -> Option<char> {
self.chars.get(self.pos).copied()
}
fn skip_spaces(&mut self) -> bool {
let start = self.pos;
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
self.pos > start
}
fn expression(&mut self, depth: usize) -> Result<Parsed, UnitError> {
self.skip_spaces();
// A leading `1` is a numerator: `1/K`, `1/s`.
let leading_one = self.peek() == Some('1') && !self.chars.get(self.pos + 1).is_some_and(char::is_ascii_digit);
let (mut dim, mut factor, mut atoms, mut offset) = if leading_one {
self.pos += 1;
self.skip_spaces();
if self.peek() != Some('/') {
return Err(self.syntax());
}
(Dimension::NONE, 1.0, 0, 0.0)
} else {
self.term(depth)?
};
loop {
let spaced = self.skip_spaces();
let divide = match self.peek() {
Some('*' | '·') => false,
Some('/') => true,
// A space between two units multiplies: `N mm`, `W/(m K)`.
Some(c) if spaced && (c == '(' || c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ')) => {
self.pos -= 1;
false
}
_ => break,
};
self.pos += 1;
self.skip_spaces();
let (d, f, a, o) = self.term(depth)?;
let overflow = || UnitError::OutOfRange(self.text.to_string());
if divide {
dim = dim.checked_div(d).ok_or_else(overflow)?;
factor /= f;
} else {
dim = dim.checked_mul(d).ok_or_else(overflow)?;
factor *= f;
}
atoms += a;
offset = o;
}
Ok((dim, factor, atoms, offset))
}
fn term(&mut self, depth: usize) -> Result<Parsed, UnitError> {
let (dim, factor, atoms, offset) = if self.peek() == Some('(') {
if depth >= MAX_DEPTH {
return Err(self.syntax());
}
self.pos += 1;
let inner = self.expression(depth + 1)?;
self.skip_spaces();
if self.peek() != Some(')') {
return Err(self.syntax());
}
self.pos += 1;
inner
} else {
self.atom()?
};
let power = self.power()?;
if power == 1 {
return Ok((dim, factor, atoms, offset));
}
let dim = dim.checked_pow(power).ok_or_else(|| UnitError::OutOfRange(self.text.to_string()))?;
// A powered unit is never a lone absolute temperature.
Ok((dim, factor.powi(i32::from(power)), atoms + 1, 0.0))
}
fn atom(&mut self) -> Result<Parsed, UnitError> {
let start = self.pos;
while let Some(c) = self.peek() {
if c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ') {
self.pos += 1;
} else {
break;
}
}
if self.pos == start {
return Err(self.syntax());
}
let name: String = self.chars.get(start..self.pos).unwrap_or_default().iter().collect();
let atom = ATOMS.iter().find(|a| a.names.contains(&name.as_str())).ok_or_else(|| UnitError::UnknownUnit(name.clone()))?;
self.angle |= matches!(atom.names.first(), Some(&("rad" | "deg")));
Ok((atom.dimension, atom.factor, 1, atom.offset))
}
/// An optional integer exponent; 1 when absent.
fn power(&mut self) -> Result<i8, UnitError> {
let mut digits = String::new();
if self.peek() == Some('^') {
self.pos += 1;
if self.peek() == Some('-') {
digits.push('-');
self.pos += 1;
}
while let Some(c) = self.peek().filter(char::is_ascii_digit) {
digits.push(c);
self.pos += 1;
}
} else {
while let Some(c) = self.peek() {
let mapped = match c {
'⁻' => '-',
'¹' => '1',
'²' => '2',
'³' => '3',
'⁴' => '4',
'⁵' => '5',
'⁶' => '6',
d if d.is_ascii_digit() => d,
_ => break,
};
digits.push(mapped);
self.pos += 1;
}
if digits.is_empty() {
return Ok(1);
}
}
match digits.parse::<i8>() {
Ok(p) if p != 0 && p.unsigned_abs() <= MAX_POWER.unsigned_abs() => Ok(p),
_ => Err(self.syntax()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-12 * a.abs().max(b.abs())
}
#[test]
fn compound_units_reduce_to_si() {
let cases = [
("t/mm³", Dimension::new(-3, 1, 0, 0), 1e12),
("t/mm^3", Dimension::new(-3, 1, 0, 0), 1e12),
("kg/m3", Dimension::new(-3, 1, 0, 0), 1.0),
("N/mm²", PRESSURE, 1e6),
("W/(m·K)", Dimension::new(1, 1, -3, -1), 1.0),
("mW/(mm*K)", Dimension::new(1, 1, -3, -1), 1.0),
("W/m/K", Dimension::new(1, 1, -3, -1), 1.0),
("K^-1", Dimension::new(0, 0, 0, -1), 1.0),
("lbf·s²/in", M, POUND_FORCE / INCH),
("mm/s", Dimension::new(1, 0, -1, 0), 1e-3),
("MPa·s", Dimension::new(-1, 1, -1, 0), 1e6),
];
for (text, dim, factor) in cases {
let u = Unit::parse(text).unwrap_or_else(|e| panic!("{text}: {e}"));
assert_eq!(u.dimension(), dim, "{text}");
assert!(close(u.factor(), factor), "{text}: {} vs {factor}", u.factor());
}
}
#[test]
fn numerators_and_spaces() {
let k = Unit::parse("1/K").unwrap();
assert_eq!(k.dimension(), Dimension::new(0, 0, 0, -1));
assert_eq!(Unit::parse("1/s").unwrap().dimension(), FREQUENCY);
assert_eq!(Unit::parse("1 / (m·K)").unwrap().dimension(), Dimension::new(-1, 0, 0, -1));
for (spaced, plain) in
[("N · mm", "N·mm"), ("W/(m K)", "W/(m·K)"), ("kg m^-3", "kg/m^3"), ("W / ( m * K )", "W/(m·K)"), ("lbf s² / in", "lbf·s²/in")]
{
let (a, b) = (Unit::parse(spaced).unwrap(), Unit::parse(plain).unwrap());
assert_eq!(a.dimension(), b.dimension(), "{spaced}");
assert!(close(a.factor(), b.factor()), "{spaced}");
}
assert!(Unit::parse("deg").unwrap().is_angle());
assert!(Unit::parse("°").unwrap().is_angle());
assert!(!Unit::parse("%").unwrap().is_angle());
}
#[test]
fn offsets_only_for_lone_temperatures() {
assert!(close(Unit::parse("°C").unwrap().to_si(20.0, true), 293.15));
assert!(close(Unit::parse("°F").unwrap().to_si(32.0, true), 273.15));
assert!(close(Unit::parse("°F").unwrap().to_si(212.0, true), 373.15));
// In a compound unit the degree is a step.
let k = Unit::parse("W/(m·°C)").unwrap();
assert!(close(k.to_si(1.0, true), 1.0));
}
#[test]
fn hostile_units_are_refused() {
for text in [
"",
" ",
"m^",
"m^0",
"m^99",
"m^-128",
"((((((m))))))",
"(m",
"m)",
"m//s",
"mm2x",
"foo",
"m^7",
"BTU",
"m.s",
"1 K",
"11/K",
"2/K",
"1/",
] {
assert!(Unit::parse(text).is_err(), "{text:?}");
}
assert!(matches!(Unit::parse(&"m".repeat(100)), Err(UnitError::TooLong)));
// Exponent overflow across many factors.
assert!(Unit::parse("(m^6)^6·(m^6)^6·(m^6)^6·(m^6)^6").is_err());
}
}
+157
View File
@@ -0,0 +1,157 @@
//! The unit table against hand-computed values.
use fieldcraft_model::units::{DisplaySystem, Preset, Quantity, Unit, UnitError, ValueInput};
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1e-300)
}
fn si(q: Quantity, text: &str) -> f64 {
q.parse(text, &DisplaySystem::new(Preset::Si)).unwrap_or_else(|e| panic!("{text}: {e}"))
}
fn shown(q: Quantity, si_value: f64, preset: Preset) -> f64 {
q.express(si_value, &DisplaySystem::new(preset).unit(q).unwrap()).unwrap()
}
#[test]
fn hand_values() {
use Quantity as Q;
// E = 210 GPa = 210 000 MPa = 2.1e11 Pa.
let e = si(Q::Pressure, "210 GPa");
assert!(close(e, 2.1e11));
assert!(close(si(Q::Pressure, "210000 MPa"), e));
assert!(close(shown(Q::Pressure, e, Preset::MmTonneS), 210_000.0));
// 1 N/mm² = 1 MPa.
assert!(close(si(Q::Pressure, "1 N/mm²"), si(Q::Pressure, "1 MPa")));
// Steel density 7850 kg/m³ = 7.85e-9 t/mm³.
let rho = si(Q::Density, "7850 kg/m³");
assert!(close(shown(Q::Density, rho, Preset::MmTonneS), 7.85e-9));
assert!(close(si(Q::Density, "7.85e-9 t/mm^3"), 7850.0));
// 20 °C = 293.15 K, but a difference of 20 °C is 20 K.
assert!(close(si(Q::Temperature, "20 °C"), 293.15));
assert!(close(si(Q::TemperatureDifference, "20 °C"), 20.0));
assert!(close(si(Q::Temperature, "68 °F"), 293.15));
assert!(close(si(Q::TemperatureDifference, "9 °F"), 5.0));
assert!(close(shown(Q::Temperature, 293.15, Preset::MmTonneS), 20.0));
assert!(close(shown(Q::Temperature, 293.15, Preset::InLbfS), 68.0));
// Expansion 12e-6 /°C = 12e-6 /K; 6.667e-6 /°F.
assert!(close(si(Q::ThermalExpansion, "12e-6 °C⁻¹"), 12e-6));
assert!(close(shown(Q::ThermalExpansion, 12e-6, Preset::InLbfS), 12e-6 * 5.0 / 9.0));
// 25 mm/s = 0.025 m/s; g = 9.81 m/s² = 9810 mm/s² = 386.22 in/s².
assert!(close(si(Q::Velocity, "25 mm/s"), 0.025));
assert!(close(shown(Q::Acceleration, 9.81, Preset::MmTonneS), 9810.0));
assert!(close(shown(Q::Acceleration, 9.81, Preset::InLbfS), 9.81 / 0.0254));
// Steel in in-lbf-s: E = 2.1e11 Pa = 30.458 Msi; ρ = 7850 kg/m³ = 7.345e-4 lbf·s²/in⁴.
let psi = 4.448_221_615_260_5 / (0.0254 * 0.0254);
assert!(close(shown(Q::Pressure, e, Preset::InLbfS), 2.1e11 / psi));
assert!(close(shown(Q::Density, rho, Preset::InLbfS), 7850.0 * 0.0254 / 4.448_221_615_260_5 * 0.0254 * 0.0254 * 0.0254));
// Steel conductivity 50 W/(m·K) is 50 mW/(mm·K); specific heat 460 J/(kg·K) is 4.6e8 mJ/(t·K).
assert!(close(shown(Q::ThermalConductivity, 50.0, Preset::MmTonneS), 50.0));
assert!(close(shown(Q::SpecificHeat, 460.0, Preset::MmTonneS), 4.6e8));
// 10 W/(m²·K) = 0.01 mW/(mm²·K); 1 kW/m² = 1 mW/mm².
assert!(close(shown(Q::HeatTransferCoefficient, 10.0, Preset::MmTonneS), 0.01));
assert!(close(shown(Q::HeatFlux, 1000.0, Preset::MmTonneS), 1.0));
// Water viscosity 1e-3 Pa·s = 1e-9 MPa·s; 1 N·mm = 1e-3 N·m = 1 mJ.
assert!(close(shown(Q::DynamicViscosity, 1e-3, Preset::MmTonneS), 1e-9));
assert!(close(si(Q::Moment, "1 N·mm"), si(Q::Energy, "1 mJ")));
// 90 deg = π/2 rad; 5 % = 0.05.
assert!(close(si(Q::Angle, "90 deg"), std::f64::consts::FRAC_PI_2));
assert!(close(si(Q::Dimensionless, "5 %"), 0.05));
}
#[test]
fn every_preset_unit_has_its_quantitys_dimension() {
for preset in Preset::ALL {
let display = DisplaySystem::new(preset);
for &q in Quantity::ALL {
let unit = display.unit(q).unwrap_or_else(|e| panic!("{preset:?} {q:?}: {e}"));
assert_eq!(unit.dimension(), q.dimension(), "{preset:?} {q:?} `{}`", unit.symbol());
}
assert_eq!(Preset::from_label(preset.label()), Some(preset));
}
}
#[test]
fn mm_tonne_second_is_consistent() {
// A consistent system: force = mass · acceleration and stress = force / area hold with no
// factor, so the deck writers can write its numbers directly.
let d = DisplaySystem::new(Preset::MmTonneS);
let f = |q| d.unit(q).unwrap().factor();
use Quantity as Q;
assert!(close(f(Q::Force), f(Q::Mass) * f(Q::Acceleration)));
assert!(close(f(Q::Pressure), f(Q::Force) / f(Q::Area)));
assert!(close(f(Q::Energy), f(Q::Force) * f(Q::Length)));
assert!(close(f(Q::Density), f(Q::Mass) / f(Q::Volume)));
let i = DisplaySystem::new(Preset::InLbfS);
let g = |q| i.unit(q).unwrap().factor();
assert!(close(g(Q::Force), g(Q::Mass) * g(Q::Acceleration)));
assert!(close(g(Q::Pressure), g(Q::Force) / g(Q::Area)));
}
#[test]
fn round_trips_through_every_preset() {
let values = [(Quantity::Pressure, 2.1e11), (Quantity::Density, 7850.0), (Quantity::Temperature, 293.15), (Quantity::Length, 0.0254)];
for preset in Preset::ALL {
let d = DisplaySystem::new(preset);
for (q, v) in values {
let text = q.format(v, &d).unwrap();
let back = q.parse(&text, &d).unwrap();
assert!((back - v).abs() <= 1e-5 * v.abs(), "{preset:?} {q:?}: {text} -> {back}");
}
}
let mm = DisplaySystem::default();
assert_eq!(Quantity::Pressure.format(2.1e11, &mm).unwrap(), "210000 MPa");
assert_eq!(Quantity::Density.format(7850.0, &mm).unwrap(), "7.85e-9 t/mm³");
assert_eq!(Quantity::Temperature.format(293.15, &mm).unwrap(), "20 °C");
assert_eq!(Quantity::Dimensionless.format(0.3, &mm).unwrap(), "0.3");
}
#[test]
fn inputs_from_commands() {
let mm = DisplaySystem::default();
let parse = |json: &str| serde_json::from_str::<ValueInput>(json).unwrap().to_si(Quantity::Pressure, &mm);
assert!(close(parse("210000").unwrap(), 2.1e11)); // bare number: display system (MPa)
assert!(close(parse(r#""210 GPa""#).unwrap(), 2.1e11));
assert!(close(parse(r#"{"value": 210, "unit": "GPa"}"#).unwrap(), 2.1e11));
assert!(close(Quantity::Pressure.parse("210000", &mm).unwrap(), 2.1e11));
assert!(serde_json::from_str::<ValueInput>(r#"{"value": 1, "unit": "Pa", "extra": 0}"#).is_err());
let over = DisplaySystem::default().with_override(Quantity::Energy, "J").unwrap();
assert_eq!(Quantity::Energy.format(2.0, &over).unwrap(), "2 J");
assert!(DisplaySystem::default().with_override(Quantity::Energy, "MPa").is_err());
}
#[test]
fn hostile_values_are_refused() {
let mm = DisplaySystem::default();
use Quantity as Q;
let wrong = Q::Pressure.parse("210 mm", &mm);
assert!(matches!(wrong, Err(UnitError::WrongDimension { .. })), "{wrong:?}");
assert_eq!(wrong.unwrap_err().to_string(), "`mm` is L, but a pressure is L^-1 M T^-2");
for text in ["", "GPa", "nan GPa", "inf Pa", "1e400 Pa", "1e300 GPa", "210 GPa extra", "210 parsecs", "--1 Pa"] {
assert!(Q::Pressure.parse(text, &mm).is_err(), "{text:?}");
}
assert!(Q::Density.parse("1e300 t/mm³", &mm).is_err());
assert!(matches!(Q::Temperature.parse("-300 °C", &mm), Err(UnitError::BelowAbsoluteZero(_))));
assert!(Q::TemperatureDifference.parse("-300 °C", &mm).is_ok());
assert!(Q::Pressure.to_si(f64::NAN, &Unit::parse("Pa").unwrap()).is_err());
assert!(Q::Pressure.parse(&"9".repeat(500), &mm).is_err());
let input = ValueInput::WithUnit(fieldcraft_model::units::WithUnit { value: f64::INFINITY, unit: "Pa".into() });
assert!(input.to_si(Q::Pressure, &mm).is_err());
}
#[test]
fn angles_and_ratios_are_kept_apart() {
let mm = DisplaySystem::default();
use Quantity as Q;
assert!(matches!(Q::Angle.parse("5 %", &mm), Err(UnitError::WrongKind { .. })));
assert!(matches!(Q::Dimensionless.parse("1 deg", &mm), Err(UnitError::WrongKind { .. })));
assert!(matches!(Q::Angle.parse("90 mm", &mm), Err(UnitError::WrongDimension { .. })));
assert!(Q::Pressure.parse("1 MPa·deg", &mm).is_err());
assert!(close(Q::Angle.parse("90 °", &mm).unwrap(), std::f64::consts::FRAC_PI_2));
assert!(close(Q::Angle.parse("90", &mm).unwrap(), std::f64::consts::FRAC_PI_2)); // display: deg
assert!(close(Q::Dimensionless.parse("0.3", &mm).unwrap(), 0.3));
assert!(close(Q::ThermalExpansion.parse("12e-6 1/K", &mm).unwrap(), 12e-6));
assert!(close(Q::Frequency.parse("0.01 1/s", &mm).unwrap(), 0.01));
assert!(close(Q::ThermalConductivity.parse("50 W/(m K)", &mm).unwrap(), 50.0));
}
+78 -41
View File
@@ -61,17 +61,49 @@ fn button_style(t: &Tokens) -> IconButtonStyle {
}
}
// OneBSpace (OneCAD Simulation): upstream's drawn fringe-cube mark (`mark`, after
// `assets/app-icon/icon-small.svg`) is replaced by a painted text wordmark, so the bar names
// the fork in every width variant (a narrow bar loses the menus, not the app's own name).
pub const APP_NAME: &str = "OneCAD Simulation";
/// FieldCraft's mark: the application icon's fringe cube (`assets/app-icon/icon-small.svg`, after
/// `xtask/src/app_icon.rs`) drawn in code without its tile or streamline: each visible face split
/// into small cells coloured by the fringe band at the cell's centre, shaded per face.
fn mark(p: &egui::Painter, c: egui::Pos2) {
// The cube is 2 units tall on screen (half side 0.5, isometric); draw it 22 points tall.
const S: f32 = 11.0;
const N: usize = 8;
const FRINGE: [(u8, u8, u8); 7] =
[(0x2c, 0x4f, 0xbf), (0x2f, 0x86, 0xe0), (0x22, 0xbc, 0xd8), (0x3f, 0xc5, 0x84), (0xf0, 0xd1, 0x3a), (0xf2, 0x8a, 0x2b), (0xde, 0x3b, 0x2f)];
const RADII: [f32; 6] = [1.28, 1.07, 0.87, 0.67, 0.47, 0.27];
let at = |q: [f32; 3]| c + vec2((q[0] - q[1]) * 0.866 * S, ((q[0] + q[1]) * 0.5 - q[2]) * S);
// Each face: origin corner, two edge vectors, and how much it is darkened (0 to 1).
let faces: [([f32; 3], [f32; 3], [f32; 3], f32); 3] = [
([-0.5, -0.5, 0.5], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.0),
([-0.5, 0.5, -0.5], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 0.12),
([0.5, -0.5, -0.5], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], 0.3),
];
let point = |o: [f32; 3], u: [f32; 3], v: [f32; 3], a: f32, b: f32| [0, 1, 2].map(|k| o[k] + u[k] * a + v[k] * b);
for (o, u, v, shade) in faces {
for i in 0..N {
for j in 0..N {
let (a0, a1, b0, b1) = (i as f32 / N as f32, (i + 1) as f32 / N as f32, j as f32 / N as f32, (j + 1) as f32 / N as f32);
let m = point(o, u, v, (a0 + a1) / 2.0, (b0 + b1) / 2.0);
let r = m.iter().map(|x| (0.5 - x) * (0.5 - x)).sum::<f32>().sqrt();
let band = RADII.iter().filter(|&&rad| r < rad).count();
let (cr, cg, cb) = FRINGE.get(band).copied().unwrap_or(FRINGE[0]);
let k = 1.0 - shade;
let col = Color32::from_rgb((cr as f32 * k) as u8, (cg as f32 * k) as u8, (cb as f32 * k) as u8);
let quad = vec![at(point(o, u, v, a0, b0)), at(point(o, u, v, a1, b0)), at(point(o, u, v, a1, b1)), at(point(o, u, v, a0, b1))];
// A hairline in the cell's own colour closes the seams between neighbouring cells.
p.add(egui::Shape::convex_polygon(quad, col, Stroke::new(0.5, col)));
}
}
}
}
/// Where the title bar's parts go.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BarLayout {
/// The wordmark's left anchor ("OneCAD Simulation", painted text).
/// The mark's centre, and where the app's name starts (none when compact).
pub mark: egui::Pos2,
/// File, Save, Undo, Redo: after the wordmark, at the left.
pub name: Option<egui::Pos2>,
/// File, Save, Undo, Redo: after the mark, at the left.
pub quick: [Rect; 4],
/// Solve, then Search, Theme and Help: at the right end, before the version.
pub solve: Rect,
@@ -80,23 +112,19 @@ pub struct BarLayout {
pub tab_room: (f32, f32),
/// The version's right edge.
pub version_right: f32,
/// Too narrow for menus: Solve without its word (the wordmark still shows).
/// Too narrow for labels: no name, and Solve without its word.
pub compact: bool,
}
/// The title bar's layout in `bar`. `traffic_lights`: the macOS window buttons sit over its left
/// end (an integrated title bar out of full screen, where there are none). `captions`: the width
/// of the custom caption buttons at its right end; `version_w`: the version text's width;
/// `name_w`: the wordmark's width (OneBSpace (OneCAD Simulation): the quick actions start after
/// the wordmark, in every width variant).
pub fn bar_layout(bar: Rect, traffic_lights: bool, captions: f32, version_w: f32, name_w: f32) -> BarLayout {
/// of the custom caption buttons at its right end; `version_w`: the version text's width.
pub fn bar_layout(bar: Rect, traffic_lights: bool, captions: f32, version_w: f32) -> BarLayout {
let left = bar.left() + if traffic_lights { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
let compact = bar.right() - left < 760.0;
let cy = bar.center().y;
let button = |x: f32| Rect::from_center_size(pos2(x + BUTTON / 2.0, cy), Vec2::splat(BUTTON));
// OneBSpace (OneCAD Simulation): the quick actions start after the wordmark in compact
// layouts too (upstream's compact bar dropped the name and started at the bare mark).
let x0 = left + 14.0 + name_w + 22.0;
let x0 = left + if compact { 44.0 } else { 132.0 };
let quick = [0.0, 1.0, 2.0, 3.0].map(|i| button(x0 + i * STRIDE));
let version_right = bar.right() - 12.0 - captions;
let tools_end = version_right - version_w - 14.0;
@@ -104,7 +132,8 @@ pub fn bar_layout(bar: Rect, traffic_lights: bool, captions: f32, version_w: f32
let solve_w = if compact { 30.0 } else { 74.0 };
let solve = Rect::from_min_size(pos2(tools[0].left() - 10.0 - solve_w, cy - 12.0), vec2(solve_w, 24.0));
BarLayout {
mark: pos2(left + 14.0, cy),
mark: pos2(left + 20.0, cy),
name: (!compact).then(|| pos2(left + 36.0, cy)),
quick,
solve,
tools,
@@ -132,11 +161,11 @@ pub fn app_bar(app: &mut ModelApp, ui: &mut egui::Ui) {
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
let version = format!("v{}", crate::VERSION);
let version_w = ui.painter().layout_no_wrap(version.clone(), FontId::proportional(11.0), t.text_dim).size().x;
// OneBSpace (OneCAD Simulation): the wordmark is measured so the quick actions clear it.
let name_w = ui.painter().layout_no_wrap(APP_NAME.to_string(), FontId::proportional(14.0), t.app_bar_text).size().x;
let lay = bar_layout(r, app.integrated_titlebar && !fullscreen(ui.ctx()), captions, version_w, name_w);
// OneBSpace (OneCAD Simulation): painted wordmark instead of the drawn mark + name.
ui.painter().text(lay.mark, Align2::LEFT_CENTER, APP_NAME, FontId::proportional(14.0), t.app_bar_text);
let lay = bar_layout(r, app.integrated_titlebar && !fullscreen(ui.ctx()), captions, version_w);
mark(ui.painter(), lay.mark);
if let Some(at) = lay.name {
ui.painter().text(at, Align2::LEFT_CENTER, "FieldCraft", FontId::proportional(14.0), t.app_bar_text);
}
let (undo, redo) = app.session.undo_depth();
let busy = app.busy().is_some();
let file_open = app.ui.menu.as_deref() == Some("file");
@@ -307,16 +336,30 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) {
ui.label("Left drag orbits (or pans or zooms: the navigation bar), right or middle drag pans, the wheel zooms, F or a double click fits.");
ui.label(format!("S or {} searches every command; the command line at the bottom runs `id {{json}}`.", shortcut("K")));
ui.separator();
// OneBSpace (OneCAD Simulation): upstream's "Report a Bug…", "Check for Updates" and
// "Reinstall FieldCraft…" items are gone — nothing here reaches GitHub or an update host.
// A report is drafted locally ("Report It…" after a crash, Help › Crash Reports) and copied
// out by hand; the version label names the fork.
let report = ui
.button("Report a Bug…")
.on_hover_text("Draft a GitHub issue with the version, the OS and the last error; nothing is sent until you submit it")
.clicked();
let (mut check, mut reinstall) = (false, false);
if app.updates_available() {
check =
ui.button("Check for Updates").on_hover_text("Ask GitHub Releases whether a newer FieldCraft is published (app.update.check)").clicked();
reinstall = ui
.button("Reinstall FieldCraft…")
.on_hover_text("Download this version again, verify it and swap it in, to repair a damaged install")
.clicked();
}
let crashes = ui.button("Crash Reports…").on_hover_text("The crash reports saved on this computer: report, copy or delete them").clicked();
let settings = ui.button("Settings…").on_hover_text("Privacy and updates").clicked();
// OneBSpace (OneCAD Simulation): the version label carries the fork's name.
ui.label(egui::RichText::new(format!("{} {}", APP_NAME, crate::VERSION)).color(t.text_dim));
if crashes {
ui.label(egui::RichText::new(format!("FieldCraft {}", crate::VERSION)).color(t.text_dim));
if report {
app.report_bug(None);
} else if crashes {
app.open_crash_reports();
} else if check {
app.check_for_updates();
} else if reinstall {
app.reinstall();
} else if settings {
app.open_settings();
}
@@ -334,16 +377,13 @@ mod tests {
/// at the very left in full screen (where there are none).
#[test]
fn the_title_bar_keeps_solve_right_and_drops_the_traffic_light_inset_in_full_screen() {
// OneBSpace (OneCAD Simulation): the layout takes the wordmark's width; a plausible
// measured value stands in for the font here.
let name_w = 120.0;
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(1400.0, HEIGHT));
let windowed = bar_layout(bar, true, 0.0, 30.0, name_w);
let full = bar_layout(bar, false, 0.0, 30.0, name_w);
let windowed = bar_layout(bar, true, 0.0, 30.0);
let full = bar_layout(bar, false, 0.0, 30.0);
assert_eq!(windowed.mark.x - full.mark.x, crate::titlebar::TRAFFIC_LIGHTS);
assert!(full.mark.x < 30.0 && full.quick[0].left() < 200.0, "{full:?}");
assert!(full.mark.x < 30.0 && full.quick[0].left() < 150.0, "{full:?}");
for l in [windowed, full] {
// Left to right: wordmark, quick actions, (tab), Solve, search, theme, help, version.
// Left to right: mark, quick actions, (tab), Solve, search, theme, help, version.
assert!(l.quick.windows(2).all(|w| w[0].right() <= w[1].left()));
assert!(l.quick[3].right() < l.tab_room.0 && l.tab_room.1 < l.solve.left());
assert!(l.solve.right() <= l.tools[0].left() && l.tools.windows(2).all(|w| w[0].right() <= w[1].left()));
@@ -352,13 +392,10 @@ mod tests {
}
// The right group does not move with full screen; custom caption buttons push it left.
assert_eq!(windowed.solve, full.solve);
let captions = bar_layout(bar, false, crate::titlebar::WIDTH, 30.0, name_w);
let captions = bar_layout(bar, false, crate::titlebar::WIDTH, 30.0);
assert!((full.tools[2].right() - captions.tools[2].right() - crate::titlebar::WIDTH).abs() < 0.01);
// Narrow: compact, the wordmark still first and the quick actions after it, in order.
let narrow = bar_layout(Rect::from_min_size(egui::Pos2::ZERO, vec2(700.0, HEIGHT)), true, 0.0, 30.0, name_w);
assert!(narrow.compact && narrow.quick[3].right() < narrow.solve.left(), "{narrow:?}");
// OneBSpace (OneCAD Simulation): every width shows the wordmark, and the quick actions
// start after it (they no longer sit on the bare mark in the compact bar).
assert!(narrow.quick[0].left() >= narrow.mark.x + name_w, "{narrow:?}");
// Narrow: compact, still in order.
let narrow = bar_layout(Rect::from_min_size(egui::Pos2::ZERO, vec2(700.0, HEIGHT)), true, 0.0, 30.0);
assert!(narrow.compact && narrow.name.is_none() && narrow.quick[3].right() < narrow.solve.left());
}
}
+1 -3
View File
@@ -77,9 +77,7 @@ pub const DEFAULT_PRESET: &str = "fieldcraft";
pub const NAV_PRESETS: [NavPreset; 7] = [
NavPreset {
id: DEFAULT_PRESET,
// OneBSpace (OneCAD Simulation): the preset's label names the fork (its `id`, which
// preferences store, stays upstream).
label: "OneCAD Simulation",
label: "FieldCraft",
help: "Left drag does the navigation bar's mode (orbit, pan or zoom); right, middle or Shift+left drag pans",
reverse_zoom: false,
bindings: &[bind(Left, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Pan), bind(Middle, NavKeys::NONE, Pan)],
+19 -27
View File
@@ -12,12 +12,10 @@ use fieldcraft_engine::usage::{self, Event, Identity, Origin};
use crate::ModelApp;
use crate::files::Prompt;
/// What is sent, event by event. (OneBSpace (OneCAD Simulation): upstream's PRIVACY_URL linked
/// out to github.com/tangency-labs/fieldcraft; the constant stays for reference but the UI no
/// longer renders it as a link or opens it — nothing here browses anywhere.)
/// What is sent, event by event.
pub const PRIVACY_URL: &str = "https://github.com/tangency-labs/fieldcraft/blob/main/docs/privacy.md";
/// The question, as the first run asks it. (OneBSpace (OneCAD Simulation): fork name.)
pub const QUESTION: &str = "Help improve OneCAD Simulation by sharing anonymous usage: which tools you use, nothing from your models.";
/// The question, as the first run asks it.
pub const QUESTION: &str = "Help improve FieldCraft by sharing anonymous usage: which tools you use, nothing from your models.";
const DETAIL: &str = "Counted: the commands you run (their names, how long they took, whether they failed), the steps from a design to results, \
the samples you open, and the version, OS and a random id for this install. Never sent: file names or paths, geometry, model \
contents, parameter values or messages.";
@@ -145,17 +143,14 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let t = crate::theme::Tokens::get(app.ui.dark);
match app.prompt {
Some(Prompt::Consent) => {
// OneBSpace (OneCAD Simulation): the privacy note is plain text — the upstream
// GitHub link (PRIVACY_URL) is not rendered as a link and never opened.
let (mut share, mut decline, mut updates) = (false, false, None);
// OneBSpace (OneCAD Simulation): the fork's name in the dialog title.
crate::frame::window(ctx, "Help Improve OneCAD Simulation", crate::frame::Width::Normal).id(egui::Id::new("fc_consent")).show(ctx, |ui| {
let (mut share, mut decline, mut link, mut updates) = (false, false, false, None);
crate::frame::window(ctx, "Help Improve FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_consent")).show(ctx, |ui| {
ui.label(QUESTION);
ui.add_space(4.0);
ui.label(RichText::new(DETAIL).color(t.text_dim).size(11.0));
ui.add_space(2.0);
ui.label(RichText::new("You can change this any time in Help › Settings.").color(t.text_dim).size(11.0));
ui.label(RichText::new("What is sent: docs/privacy.md in the repository.").color(t.text_dim).size(11.0));
link = ui.link("What is sent (docs/privacy.md)").on_hover_text(PRIVACY_URL).clicked();
if app.updates_available() {
ui.add_space(4.0);
updates = update_check(ui, &t, app.prefs.update_check);
@@ -166,6 +161,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
decline = ui.button("Don't Share").clicked();
});
});
if link {
app.open_link(ctx, PRIVACY_URL);
}
if let Some(on) = updates {
app.set_update_prefs(on, None);
}
@@ -178,20 +176,15 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
Some(Prompt::Settings) => {
let available = app.usage_available();
let (mut sharing, mut automation) = (app.prefs.usage_sharing == Some(true), app.prefs.usage_automation);
// OneBSpace (OneCAD Simulation): the privacy note is plain text — no link, no
// browser. The dialog never opens PRIVACY_URL.
let (mut close, mut updates, mut unskip, mut check) = (false, None, false, false);
let (mut close, mut link, mut updates, mut unskip, mut check) = (false, false, None, false, false);
crate::frame::window(ctx, "Settings", crate::frame::Width::Normal).id(egui::Id::new("fc_settings")).show(ctx, |ui| {
if app.updates_available() {
crate::frame::menu_heading(ui, &t, "UPDATES");
updates = update_check(ui, &t, app.prefs.update_check);
ui.horizontal(|ui| {
// OneBSpace (OneCAD Simulation): the tooltip no longer points at Help ›
// Check for Updates (that menu item was removed with the fork's rebrand).
check = ui.button("Check Now").on_hover_text("Ask GitHub Releases for the latest version now").clicked();
check = ui.button("Check Now").on_hover_text("Help › Check for Updates").clicked();
if !app.prefs.update_skip.is_empty() {
// OneBSpace (OneCAD Simulation): the fork's name in the skip note.
ui.label(RichText::new(format!("Skipping OneCAD Simulation {}", app.prefs.update_skip)).color(t.text_dim).size(11.0));
ui.label(RichText::new(format!("Skipping FieldCraft {}", app.prefs.update_skip)).color(t.text_dim).size(11.0));
unskip = ui.small_button("Offer It Again").clicked();
}
});
@@ -214,13 +207,11 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
}
ui.label(RichText::new(DETAIL).color(t.text_dim).size(11.0));
ui.label(
// OneBSpace (OneCAD Simulation): the report flow is local-only now (a draft
// to copy), so the note no longer promises a GitHub submission.
RichText::new("Crash reports and the log stay on this computer; a report is drafted here and copied out — nothing is sent anywhere.")
RichText::new("Crash reports and the log stay on this computer; Help › Report a Bug sends only what you submit.")
.color(t.text_dim)
.size(11.0),
);
ui.label(RichText::new("What is sent: docs/privacy.md in the repository.").color(t.text_dim).size(11.0));
link = ui.link("What is sent (docs/privacy.md)").on_hover_text(PRIVACY_URL).clicked();
ui.add_space(6.0);
close = ui.button("Close").clicked();
});
@@ -233,6 +224,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
if check {
app.check_for_updates();
}
if link {
app.open_link(ctx, PRIVACY_URL);
}
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
}
@@ -247,13 +241,11 @@ fn update_check(ui: &mut egui::Ui, t: &crate::theme::Tokens, on: bool) -> Option
let changed = ui
.checkbox(&mut checked, "Check for updates automatically")
.on_hover_text(
// OneBSpace (OneCAD Simulation): the fork's name in the tooltip (desktop-only check).
"At start-up, at most once a day, OneCAD Simulation asks GitHub Releases for the latest version. Nothing installs until you choose Install.",
"At start-up, at most once a day, FieldCraft asks GitHub Releases for the latest version. Nothing installs until you choose Install.",
)
.changed();
ui.label(
// OneBSpace (OneCAD Simulation): the fork's name in the note.
RichText::new("The check sends only the request (your IP address and the OneCAD Simulation version reach GitHub); off means no check.")
RichText::new("The check sends only the request (your IP address and the FieldCraft version reach GitHub); off means no check.")
.color(t.text_dim)
.size(11.0),
);
+53 -54
View File
@@ -1,13 +1,13 @@
//! Help › Crash Reports, "Report It…" after a crash ("OneCAD Simulation closed unexpectedly —
//! Report it"), and the Report a Bug form they open.
//! Help › Report a Bug, and the question the window asks after a crash ("FieldCraft closed
//! unexpectedly — Report it").
//!
// OneBSpace (OneCAD Simulation): upstream opened the filled GitHub issue form in the browser;
// the fork's report flow is local-only — the form shows exactly what the draft holds
// ([`fieldcraft_engine::bug_report`]): the version, the OS, what happened (the last error by
// default) and, after a crash, where it happened. **Share my session** adds the commands that
// built the model, **Include the model file** the model as well. **Copy report** puts it all on
// the clipboard (nothing opens, nothing is sent); attachments are saved to a local folder to
// attach from wherever the user files their reports.
//! The form shows exactly what the GitHub issue form will be filled with
//! ([`fieldcraft_engine::bug_report`]): the version, the OS, what happened (the last error by
//! default) and, after a crash, where it happened. **Share my session** adds the commands that
//! built the model, **Include the model file** the model as well. Nothing leaves the computer
//! until the user clicks **Open GitHub Issue**, which opens the filled form in their browser (they
//! submit it there, or not); files too big for the link are saved to a folder to attach from, and
//! a long session is also put on the clipboard.
use egui::RichText;
use fieldcraft_engine::bug_report::{self, Draft, Input};
@@ -169,29 +169,37 @@ impl ModelApp {
}
}
// OneBSpace (OneCAD Simulation): the report never leaves the workspace — no GitHub issue
// form is opened (upstream opened the draft's ISSUE_URL). The draft's text goes to the
// clipboard and any attachments stay in the local bug-reports folder to attach by hand.
fn copy_report(&mut self, ctx: &egui::Context, draft: &Draft) {
let mut text = draft.title.clone();
for (k, v) in &draft.fields {
if !v.is_empty() {
text.push_str(&format!("\n\n{k}:\n{v}"));
}
}
if let Some(session) = &draft.clipboard {
text.push_str(&format!("\n\nsession:\n{session}"));
}
ctx.copy_text(text);
let note = if draft.attachments.is_empty() {
"Report copied to the clipboard — paste it into your own issue tracker.".to_string()
} else {
/// Open the draft in the browser: attachments saved to a folder of their own, the session on
/// the clipboard when it is too long for the link.
fn send_report(&mut self, ctx: &egui::Context, draft: &Draft) {
let mut saved = None;
if !draft.attachments.is_empty() {
match bug_report::save_attachments(draft) {
Ok(dir) => format!("Report copied to the clipboard: attach the files in {}.", dir.display()),
Err(e) => format!("Report copied to the clipboard (the files to attach could not be saved: {e})."),
Ok(dir) => saved = Some(dir),
Err(e) => {
self.set_status(format!("Report a Bug: could not save the files to attach: {e}"), true);
return;
}
}
}
if let Some(text) = &draft.clipboard {
ctx.copy_text(text.clone());
}
match &self.services.open_url {
Some(open) => {
if let Err(e) = open(&draft.url) {
self.set_status(format!("Report a Bug: could not open the browser: {e}"), true);
return;
}
}
None => ctx.open_url(egui::OpenUrl::new_tab(draft.url.clone())),
}
log::info!("bug report opened in the browser ({} bytes, {} attachment(s))", draft.url.len(), draft.attachments.len());
let text = match saved {
Some(dir) => format!("The issue form opened in your browser: attach the files in {} and submit it there.", dir.display()),
None => "The issue form opened in your browser: review it and submit it there.".to_string(),
};
self.set_status(note, false);
self.set_status(text, false);
self.prompt = None;
self.report = None;
}
@@ -218,11 +226,10 @@ fn size(bytes: usize) -> String {
}
fn kind_label(kind: &str) -> &'static str {
// OneBSpace (OneCAD Simulation): the fork's name in the crash headings.
match kind {
"unclean_exit" => "OneCAD Simulation closed unexpectedly",
"unclean_exit" => "FieldCraft closed unexpectedly",
"command_panic" => "A command hit an internal error",
_ => "OneCAD Simulation hit an internal error",
_ => "FieldCraft hit an internal error",
}
}
@@ -238,9 +245,8 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let (mut report, mut close) = (false, false);
crate::frame::window(ctx, kind_label(&first.kind), crate::frame::Width::Normal).id(egui::Id::new("fc_crashed")).show(ctx, |ui| {
ui.label(match first.kind.as_str() {
// OneBSpace (OneCAD Simulation): the fork's name in the crash question.
"unclean_exit" => "OneCAD Simulation closed unexpectedly the last time it ran.",
_ => "OneCAD Simulation hit an internal error the last time it ran.",
"unclean_exit" => "FieldCraft closed unexpectedly the last time it ran.",
_ => "FieldCraft hit an internal error the last time it ran.",
});
ui.label(RichText::new(&first.message).color(t.text_dim));
if reports.len() > 1 {
@@ -248,10 +254,8 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
}
ui.add_space(4.0);
ui.label(
// OneBSpace (OneCAD Simulation): reporting drafts locally — no GitHub issue
// is opened; the user copies the report into their own tracker.
RichText::new(
"A crash report was saved on this computer. Reporting it drafts a report that stays in the workspace: copy it into your own issue tracker; nothing is sent anywhere.",
"A crash report was saved on this computer. Reporting it opens a GitHub issue you review first; nothing is sent until you submit it.",
)
.color(t.text_dim),
);
@@ -281,9 +285,7 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
ui.label("No crash reports are saved on this computer.");
} else {
ui.label(
// OneBSpace (OneCAD Simulation): reporting drafts locally — no GitHub
// issue is opened; the report is copied out by hand.
RichText::new("Saved on this computer, newest first. Reporting one drafts a report that stays in the workspace: copy it into your own issue tracker.")
RichText::new("Saved on this computer, newest first. Reporting one opens a GitHub issue you review first; nothing is sent until you submit it.")
.color(t.text_dim),
);
ui.add_space(4.0);
@@ -342,14 +344,14 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
app.prompt = None;
return;
};
let (mut copy, mut close) = (false, false);
let (mut send, mut close) = (false, false);
let commands = form.input.commands.len();
crate::frame::window(ctx, "Report a Bug", crate::frame::Width::Wide).id(egui::Id::new("fc_report_bug")).show(ctx, |ui| {
ui.label(
// OneBSpace (OneCAD Simulation): the report stays in the workspace — it is
// copied to the clipboard, never opened as a GitHub issue in the browser.
RichText::new("Your report stays in the workspace: copy it and paste it into your own issue tracker. Nothing is sent anywhere.")
.color(t.text_dim),
RichText::new(
"This opens a GitHub issue in your browser, filled in with what is shown below. Nothing is sent until you submit it there.",
)
.color(t.text_dim),
);
if let Some(p) = &form.problem {
ui.label(RichText::new(p).color(t.error));
@@ -384,12 +386,11 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let draft = form.draft().clone();
ui.add_space(6.0);
ui.horizontal(|ui| {
// OneBSpace (OneCAD Simulation): "Copy report" instead of "Open GitHub Issue".
copy = ui.button("Copy report").clicked();
send = ui.button("Open GitHub Issue").clicked();
close = ui.button("Cancel").clicked();
});
ui.add_space(4.0);
egui::CollapsingHeader::new("What is in the report").id_salt("fc_report_sent").default_open(true).show(ui, |ui| {
egui::CollapsingHeader::new("What is sent").id_salt("fc_report_sent").default_open(true).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
ui.label(RichText::new(format!("Title: {}", draft.title)).monospace().size(11.0));
for (k, v) in &draft.fields {
@@ -402,17 +403,15 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
ui.label(RichText::new(format!("Attach {} ({}): {}", a.name, size(a.bytes.len()), a.what)).size(11.0).color(t.accent));
}
if draft.clipboard.is_some() {
// OneBSpace (OneCAD Simulation): the session is copied with the report.
ui.label(RichText::new("The session is copied with the report.").size(11.0).color(t.text_dim));
ui.label(RichText::new("The session goes on the clipboard too, to paste.").size(11.0).color(t.text_dim));
}
});
});
});
let draft = form.draft().clone();
app.report = Some(form);
// OneBSpace (OneCAD Simulation): the copied report never touches the network.
if copy {
app.copy_report(ctx, &draft);
if send {
app.send_report(ctx, &draft);
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
app.report = None;
+16 -22
View File
@@ -123,9 +123,9 @@ impl ModelApp {
self.set_status(format!("Cannot reinstall: {why}"), true);
return;
}
u.busy = Some(format!("Downloading OneCAD Simulation {}…", crate::VERSION)); // OneBSpace (OneCAD Simulation): fork name in the status
u.busy = Some(format!("Downloading FieldCraft {}…", crate::VERSION));
(u.host.start)(Job::Reinstall);
self.set_status(format!("Downloading OneCAD Simulation {} to reinstall it…", crate::VERSION), false); // OneBSpace (OneCAD Simulation)
self.set_status(format!("Downloading FieldCraft {} to reinstall it…", crate::VERSION), false);
}
/// The notice's Install: download and verify, then ask to restart. A copy that cannot
@@ -133,16 +133,15 @@ impl ModelApp {
pub fn install_update(&mut self, ctx: &egui::Context) {
let Some(u) = &mut self.updates else { return };
let Some(release) = u.available.clone() else { return };
// OneBSpace (OneCAD Simulation): the fork's name in the not-signed status.
let blocked = u.host.unsupported.clone().or_else(|| (!release.signed()).then(|| format!("OneCAD Simulation {} is not signed", release.version)));
let blocked = u.host.unsupported.clone().or_else(|| (!release.signed()).then(|| format!("FieldCraft {} is not signed", release.version)));
if let Some(why) = blocked {
self.set_status(format!("{why}: opening the download page"), true);
self.open_link(ctx, &release.url);
return;
}
u.busy = Some(format!("Downloading OneCAD Simulation {}…", release.version)); // OneBSpace (OneCAD Simulation)
u.busy = Some(format!("Downloading FieldCraft {}…", release.version));
(u.host.start)(Job::Stage(release.clone()));
self.set_status(format!("Downloading OneCAD Simulation {}…", release.version), false); // OneBSpace (OneCAD Simulation)
self.set_status(format!("Downloading FieldCraft {}…", release.version), false);
}
/// Skip the offered version: the automatic check no longer offers it.
@@ -179,7 +178,7 @@ impl ModelApp {
&& let Some((s, Finish::Swap(_))) = &u.staged
{
let v = s.version.to_string();
self.set_status(format!("OneCAD Simulation {v} installs when you quit"), false); // OneBSpace (OneCAD Simulation)
self.set_status(format!("FieldCraft {v} installs when you quit"), false);
}
return;
}
@@ -235,16 +234,14 @@ impl ModelApp {
let v = check.latest.version.to_string();
u.available = Some(check.latest);
if manual {
// OneBSpace (OneCAD Simulation): the fork's name in the status.
self.set_status(format!("OneCAD Simulation {v} is available"), false);
self.set_status(format!("FieldCraft {v} is available"), false);
}
}
Ok(check) => {
// Nothing newer (or skipped): a notice from an earlier check goes.
u.available = None;
if manual {
// OneBSpace (OneCAD Simulation): the fork's name in the status.
self.set_status(format!("OneCAD Simulation {} is up to date", check.current), false);
self.set_status(format!("FieldCraft {} is up to date", check.current), false);
}
}
Err(e) if manual => self.set_status(format!("Could not check for updates: {e}"), true),
@@ -313,8 +310,7 @@ pub fn notice(app: &mut ModelApp, ui: &mut egui::Ui) {
if ui.add(egui::Link::new(RichText::new("Reload to update").size(11.5))).on_hover_text(tip).clicked() {
reload();
}
// OneBSpace (OneCAD Simulation): the fork's name in the status-bar notice.
ui.label(RichText::new("A new version of OneCAD Simulation is available —").size(11.5).color(t.text));
ui.label(RichText::new("A new version of FieldCraft is available —").size(11.5).color(t.text));
ui.separator();
return;
}
@@ -332,8 +328,7 @@ pub fn notice(app: &mut ModelApp, ui: &mut egui::Ui) {
let install = ui.add(egui::Link::new(small("Install"))).on_hover_text("Download, verify and install it; you choose when to restart").clicked();
ui.label(small("·").color(t.text_dim));
let notes = ui.add(egui::Link::new(small("What's new"))).on_hover_text(release.url.as_str()).clicked();
// OneBSpace (OneCAD Simulation): the fork's name in the status-bar notice.
ui.label(small(&format!("OneCAD Simulation {} is available —", release.version)).color(t.text));
ui.label(small(&format!("FieldCraft {} is available —", release.version)).color(t.text));
ui.separator();
if notes {
app.open_link(ui.ctx(), &release.url);
@@ -358,13 +353,12 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
};
let reinstall = staged.version.to_string() == crate::VERSION;
let (mut restart, mut later, mut open) = (false, false, false);
// OneBSpace (OneCAD Simulation): the fork's name in the window title.
crate::frame::window(ctx, "Update OneCAD Simulation", crate::frame::Width::Normal).id(egui::Id::new("fc_update")).show(ctx, |ui| match &finish {
crate::frame::window(ctx, "Update FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_update")).show(ctx, |ui| match &finish {
Finish::Swap(_) => {
ui.label(if reinstall {
format!("OneCAD Simulation {} was downloaded again and verified. Restart to reinstall it?", staged.version)
format!("FieldCraft {} was downloaded again and verified. Restart to reinstall it?", staged.version)
} else {
format!("OneCAD Simulation {} is downloaded and verified. Restart now to finish installing it?", staged.version)
format!("FieldCraft {} is downloaded and verified. Restart now to finish installing it?", staged.version)
});
ui.label(
RichText::new(format!("It replaces {}. Unsaved changes are saved or asked about first.", staged.install.describe()))
@@ -374,11 +368,11 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
ui.add_space(6.0);
ui.horizontal(|ui| {
restart = ui.button("Restart Now").clicked();
later = ui.button("Later").on_hover_text("It installs when you quit OneCAD Simulation").clicked(); // OneBSpace (OneCAD Simulation)
later = ui.button("Later").on_hover_text("It installs when you quit FieldCraft").clicked();
});
}
Finish::Package { file, command } => {
ui.label(format!("OneCAD Simulation {} is downloaded and verified:", staged.version)); // OneBSpace (OneCAD Simulation)
ui.label(format!("FieldCraft {} is downloaded and verified:", staged.version));
ui.label(RichText::new(file.display().to_string()).monospace().size(11.0));
ui.label("Install it with your system's package manager:");
let mut text = command.clone();
@@ -522,7 +516,7 @@ mod tests {
tx.send(Event::Checked { manual: true, result: Ok(Check { current: current.clone(), latest: release(crate::VERSION) }) }).unwrap();
a.poll_updates(&ctx);
assert_eq!(a.status.as_ref().map(|s| s.text.clone()), Some(format!("OneCAD Simulation {} is up to date", crate::VERSION))); // OneBSpace (OneCAD Simulation)
assert_eq!(a.status.as_ref().map(|s| s.text.clone()), Some(format!("FieldCraft {} is up to date", crate::VERSION)));
assert_eq!(a.update_offered(), None, "the earlier offer goes");
tx.send(Event::Checked { manual: false, result: Err("offline".into()) }).unwrap();
a.status = None;
+107
View File
@@ -0,0 +1,107 @@
# Units inventory (#579)
Every place a dimensional number enters or leaves FieldCraft today, the unit it is assumed to be
in, and where the SI conversion goes. Today nothing converts: every stored `f64` means "whatever
consistent system the user chose", labelled by `model.units` (`UnitSystem`,
`crates/model/src/units/mod.rs`). The SI table that later steps convert through is
`crates/model/src/units/` (`Quantity`, `Unit`, `DisplaySystem`). **Risky** marks an implicit
assumption, a hard-coded factor or a missing dimension.
## Model storage (`crates/model/src`): all bare `f64`, no unit tag
Conversion goes at the command/file boundary; storage becomes SI.
| Where | Fields | Quantity |
|---|---|---|
| `lib.rs` | `units: UnitSystem` (serde default `unspecified`); `MAX_COORD = 1e9` "model units" | label; length |
| `mesh.rs`, `datums.rs`, `constraints.rs`, `relaxations.rs`, `mesh_plans.rs` | node/datum/reference point xyz, plan centre/radius/size/tolerance | length |
| `sizes.rs`, `seeds.rs`, `virtual_faces.rs`, `tie.rs` | size regions, seed sizes, tolerances, position_tolerance | length (bias, growth: dimensionless) |
| `materials.rs` | young, shear, hyperelastic c01/c20/c30, yield_stress, hardening_modulus, hardening table stress, JC a/b | pressure |
| | poisson, strains, JC n/c/m, taylor_quinney, damage d1–d5 | dimensionless |
| | JC reference_strain_rate | frequency (1/s) |
| | JC room/melt temperature, thermal reference_temperature | temperature (absolute) |
| | conductivity, specific_heat, expansion | conductivity, specific heat, expansion (1/ΔT) |
| | density; fluid viscosity (doc: "1e-9 MPa·s for water") | density; dynamic viscosity |
| `sections.rs` | profile dims, shell thickness/offset, 2D thickness, area | length, area |
| `loads.rs` | force, moment, traction, pressure, gravity, body force, centrifugal ω/point, surface flux, film, sink, heat source power, temperature | force, moment, pressure, acceleration, force/volume, angular velocity, heat flux, HTC, temperature, heat generation |
| `bcs.rs` | displacement `[Option<f64>; 6]` (3 lengths + 3 rotations rad), temperature | length, angle, temperature |
| `steps.rs` | frequency min/max_hz, buckle shift, end_time/increments/intervals, initial velocity, rigid wall point, hourglass/bulk viscosity coefs, heat theta/initial_temperature, imperfection scale, displacement control | frequency, time, velocity, length, temperature (coefs dimensionless) |
| `contact.rs` | μ static/kinetic, penalty_scale; **decay (time per length)** | dimensionless; s/m |
| `discrete.rs` | point mass, inertia, spring stiffness, dashpot coefficient | mass, mass·length², stiffness, damping |
| `fluids.rs` | wall/inlet velocity, pressure, thermal temperature/flux, initial temperature, gravity, β, domain min/max/spacing/tolerance | velocity, pressure, temperature, heat flux, acceleration, expansion, length |
**Risky:** `units` missing in a file means `unspecified`, so a migration cannot know the system of
old files without one; the step cache key includes `units`.
## Commands and forms (`crates/engine/src/cmd`, `schema.rs`, `cmd/forms.rs`)
Definition commands (`model.{material,section,load,boundary_condition,…}.{create,edit}`) deserialize
JSON straight into the model structs (`cmd/define.rs`), so every number is a bare `f64`. Each form
field carries a `Dim` (`schema.rs:299-418`); `Dim::unit(system)` only builds a label. Conversion
goes where the form value is read: `Field` → `Quantity` and `ValueInput::to_si`.
**Risky:**
- `Dim::Millimetre` is a fixed "mm" for meshing and geometry commands (tet/hex size, tolerances,
datums, hex project, defeature, mesh plan, geometry check), while the same quantities in mesh
editing commands (node add/move/relax, seeds, size regions, block, mirror, planar, stitch) are
`Dim::Length` and follow the system. One physical quantity, two assumptions.
- Missing dimensions: load heat_source power (`forms.rs:561`, really W/m³), frequency shift
(`forms.rs:2024`, really 1/s²), contact decay (`forms.rs:1015`, s/m), JC reference strain
rate (`forms.rs:137`, 1/s).
- `Dim::Temperature` has no unit; `Dim::Expansion` is fixed "1/K" while temperatures are unlabelled.
- JSON Schema (`schema.rs:1260-1365`, list_commands, `fieldcraft-cli commands --json`, MCP) emits
only `x-dimension` names and none for vectors and number_or_auto (forces, positions, gravity,
velocities lose their dimension).
- Help/defaults assume mm-t-s: `forms.rs:520` (gravity −9810), `define.rs:98` (210000, 7.85e-9),
`define.rs:291`; samples `engine/src/sample.rs:362` `GRAVITY = 9810.0` and aluminium
70000 / 2.7e-9.
- `model.units` (`cmd/model.rs:39-54`) only sets the label; it warns about mm geometry only while a
design is open.
## UI display (`crates/ui-egui`)
Forms (`form.rs`) label through `Field::unit_label/help_text`; descriptions (`describe.rs`),
replies (`reply.rs`), results panel and probes (`results_panel.rs`, `lib.rs` `probe_rows`) through
`Quantity::unit(system)`. Display conversion goes here (`Quantity::format` with the display
system). **Risky:** literal "mm", "mm²", "mm³" in `reply.rs:398, 708, 720, 723, 770` and
`feature_graph.rs:52-257`.
## Results (`crates/engine/src/results.rs`, `flow.rs`, `cmd/xy.rs`, `cmd/results.rs`)
`Field::quantity()` maps fields to the engine's `Quantity` (Length, Stress, Force, Moment, Mass,
Time, Velocity, Temperature, Energy, HeatFlow, HeatFlux); `Quantity::unit` builds the label, with
per-system strings for energy/power/flux. Reactions and probe replies are numbers only; contour
replies carry `"unit"`. **Risky:** temperature is always `"°C or K"` (also in in-lbf-s);
`ContactGap` (a length) maps to `None`. Conversion: results stay SI; legends and replies format.
## Files
| Format | Today | Conversion |
|---|---|---|
| `.fieldcraft` (`crates/io`, `FORMAT = "fieldcraft/1"`) | `model.json` numbers as entered plus `"units"`; `mesh.bin` raw coordinates | scale to SI once on open by the declared system. **Risky:** FieldCraft has no pending-format mechanism (only the `FORMAT` const and its refusal); SI storage needs a format version step. Files with `unspecified` need a rule (assume mm-t-s-N-MPa, since geometry is mm). |
| Abaqus / Nastran / LS-DYNA (`crates/decks`, write only) | unit label in a comment only | write in a chosen consistent system (`DisplaySystem` factors). **Risky:** no Nastran `PARAM,WTMASS`, no LS-DYNA `*CONTROL_UNITS`; there are no deck readers yet. |
| SolidCraft geometry (`crates/geometry`) | "lengths are millimetres" (`lib.rs:16, 96`) | ×1e-3 once at import |
| 3MF (`geometry/src/tagged_mesh.rs:151-161`) | **the one real conversion outside the table**: micron/mm/cm/inch/foot/metre → mm; writes `unit="millimeter"` | move factors to the table, convert to m |
| STEP | no import exists | — |
| STL export (`io/src/stl.rs`) | raw coordinates | write in the display length (STL has no unit field) |
| VTU/PVD (`io/src/vtk.rs`) | no unit metadata | add units in field data |
| CSV (`cmd/results.rs:1023-1075`) | bare headers; reply has `"units"` | units in headers (`fx [N]`) |
## CLI and MCP
`crates/mcp/src/server.rs:26-27` instructions: "unit-consistent and never converts … use
mm-t-s-N-MPa"; `tools.rs:105` example `model.units`; screenshot distance "in model units". The
CLI adds no units itself (commands schema as above). Replies switch to values with units.
## Solvers
- LBM (`crates/lbm/src/setup.rs`): lattice units Δx = Δt = ρ₀ = 1; ν, velocities, accelerations,
pressure, force, heat scale through h, dt, ρ, c. Self-consistent in any system, no fixed
factors; unchanged by SI storage. Temperatures pass unscaled (only differences matter).
- Implicit, explicit, fem: no unit literals; thermal strain uses α(T − T_ref), Johnson–Cook uses
room/melt in one scale: correct in kelvin. No 273.15 or Stefan–Boltzmann anywhere (radiation
would need absolute kelvin, which SI storage gives).
## Literal factors
`cargo xtask unit-factors` lists float literals equal to common factors outside the table: 548
today (mesh 187, engine 175, geometry 34, render 33, fem 25, …). Almost all are tolerances,
limits or bytes→MB; the real conversions are the 3MF table and the mm-t-s sample constants.
## Implicit "geometry in mm"
Mesh coordinates come from mm geometry whatever `model.units` says: an m-kg-s or in-lbf-s model
reads mm numbers as metres or inches; the only guard is the `model.units` warning. SI storage
fixes this at the import boundary. `MAX_COORD` is "1e9 model units" in code, "±10⁹ mm" in
`docs/formats/tagged-mesh.md:45`.
+4
View File
@@ -13,6 +13,7 @@ mod layers;
mod licences;
mod skill;
mod test;
mod unit_factors;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -53,6 +54,8 @@ commands:
and the flags CI plans its jobs with: everything (manifest, lock file,
toolchain, xtask or workflows changed), solver (the heavy tests or the oracle
are affected), wasm (a crate the wasm check builds is affected)
unit-factors report literal unit-conversion factors (1e-3, 1e6, 273.15, 25.4, …) written
outside the unit table, crates/model/src/units (report only, #579)
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
ci [--affected REV]
fmt --check, assets, layers, clippy -D warnings, then the tests
@@ -102,6 +105,7 @@ fn main() -> ExitCode {
c.args(["run", "--release", "-p", affected::ORACLE_PACKAGE, "--bin", "oracle", "--"]).args(&rest);
run(c, "oracle")
}
Some("unit-factors") => unit_factors::run(&root()),
Some("wasm") => cmd_wasm(),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
+134
View File
@@ -0,0 +1,134 @@
//! `cargo xtask unit-factors`: report literal unit-conversion factors (1e-3, 1e6, 273.15, 25.4, …)
//! written outside the unit table (`crates/model/src/units/`).
//!
//! Issue #579 routes every conversion through the table. Until the later steps have removed the
//! existing factors this is a report and never fails; once the count is zero it becomes a CI check.
//! Many hits are tolerances or algorithm constants rather than conversions: the report lists them
//! all so each can be classified.
use std::path::Path;
/// The factors flagged, with what each usually converts.
const FACTORS: &[(f64, &str)] = &[
(1e-3, "milli (mm↔m, ms↔s, g↔kg)"),
(1e3, "kilo (t↔kg, m↔mm)"),
(1e6, "mega (MPa↔Pa, mm²↔m²)"),
(1e9, "giga (GPa↔Pa, mm³↔m³)"),
(1e-6, "micro (Pa↔MPa)"),
(1e-9, "nano (Pa↔GPa, m³↔mm³)"),
(1e12, "t/mm³↔kg/m³"),
(1e-12, "kg/m³↔t/mm³"),
(273.15, "°C↔K"),
(459.67, "°F↔°R"),
(25.4, "in↔mm"),
(0.0254, "in↔m"),
(9.80665, "standard gravity"),
(9.81, "gravity"),
(9810.0, "gravity in mm/s²"),
];
/// The one place factors belong.
const TABLE: &str = "crates/model/src/units/";
const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist", "xtask", "tests", "node_modules"];
pub fn run(root: &Path) -> Result<(), String> {
let mut files = Vec::new();
for dir in ["crates", "apps"] {
walk(&root.join(dir), &mut files);
}
files.sort();
let mut hits = Vec::new();
for path in &files {
let rel = path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/");
if rel.starts_with(TABLE) {
continue;
}
let Ok(text) = std::fs::read_to_string(path) else { continue };
for (n, line) in text.lines().enumerate() {
// Unit tests sit at the end of a file behind `#[cfg(test)]`; their values are data.
if line.trim_start().starts_with("#[cfg(test)]") {
break;
}
let code = line.split("//").next().unwrap_or("");
for value in literals(code) {
if let Some((_, what)) = FACTORS.iter().find(|(f, _)| *f == value) {
hits.push(format!("{rel}:{}: {value:e} ({what}): {}", n + 1, line.trim()));
}
}
}
}
for h in &hits {
println!("{h}");
}
println!("unit-factors: {} literal factor(s) outside {TABLE} (report only; #579 drives this to zero)", hits.len());
Ok(())
}
fn walk(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
let name = e.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
walk(&p, out);
}
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
/// The floating-point literals on a line of code, parsed (`1_000.0`, `1e-3`,
/// `273.15_f64`). Digits inside identifiers (`vec3`) are skipped.
fn literals(code: &str) -> Vec<f64> {
let chars: Vec<char> = code.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while let Some(&c) = chars.get(i) {
let prev_ident = i > 0 && chars.get(i - 1).is_some_and(|p| p.is_alphanumeric() || *p == '_' || *p == '.');
if !c.is_ascii_digit() || prev_ident {
i += 1;
continue;
}
let start = i;
while let Some(&d) = chars.get(i) {
let exp_sign = matches!(d, '+' | '-') && matches!(chars.get(i.wrapping_sub(1)), Some('e' | 'E'));
if d.is_ascii_digit() || d == '_' || d == '.' || d == 'e' || d == 'E' || exp_sign {
// `0..n` is a range, `1.max(x)` a method call: stop at a dot not followed by a digit.
if d == '.' && !chars.get(i + 1).is_some_and(char::is_ascii_digit) {
break;
}
i += 1;
} else {
break;
}
}
let text: String = chars.get(start..i).unwrap_or_default().iter().filter(|c| **c != '_').collect();
let suffix_start = i;
// Skip a type suffix (`f64`, `_f32`, `u8`).
while chars.get(i).is_some_and(|c| c.is_alphanumeric() || *c == '_') {
i += 1;
}
let suffix: String = chars.get(suffix_start..i).unwrap_or_default().iter().collect();
// Conversions are floating-point arithmetic; integer literals are counts and caps.
let float = text.contains(['.', 'e', 'E']) || suffix.trim_start_matches('_').starts_with('f');
if float && let Ok(v) = text.parse::<f64>() {
out.push(v);
}
}
out
}
#[cfg(test)]
mod tests {
use super::literals;
#[test]
fn literals_on_a_line() {
assert_eq!(literals("let x = 1e-3 * y + 273.15_f64;"), vec![1e-3, 273.15]);
assert_eq!(literals("for i in 0..1000 { vec3(1_000.0) }"), vec![1000.0]);
assert_eq!(literals("a.max(1.0e6) - 2.max(b)"), vec![1e6]);
assert_eq!(literals("t.0 + x1 + 25.4f32 + 3f64"), vec![25.4, 3.0]);
}
}