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 1565 additions and 2797 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> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> <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."> <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 <meta property="og:type" content="website">
stripped — no social junk in this app's bundle. --> <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="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="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="../../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"> <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> <style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262c37; } 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_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; #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; } 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 img { width: 96px; height: 96px; }
#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 p { margin: 0; max-width: 32em; } #fieldcraft_loading p { margin: 0; max-width: 32em; }
#fieldcraft_loading.failed { pointer-events: auto; } #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; } .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); } } @keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } @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> </style>
</head> </head>
<body> <body>
<canvas id="fieldcraft_canvas"></canvas> <canvas id="fieldcraft_canvas"></canvas>
<div id="fieldcraft_loading" role="status"> <div id="fieldcraft_loading">
<span class="wordmark">OneCAD Simulation</span> <img src="icons/icon_128x128@2x.png" alt="">
<div class="spinner"></div> <div class="spinner"></div>
<span class="sr-only">Loading OneCAD Simulation&hellip;</span> <p>Loading FieldCraft&hellip;</p>
<!-- // OneBSpace (OneCAD Simulation): the no-JavaScript note names the fork --> <noscript><p>FieldCraft needs JavaScript and WebAssembly.</p></noscript>
<noscript><p>OneCAD Simulation needs JavaScript and WebAssembly.</p></noscript>
</div> </div>
<script> <script>
// The page could not load or start the app (no WebAssembly, a failed download): say so // 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; if (!el || el.classList.contains("failed")) return;
el.classList.add("failed"); el.classList.add("failed");
el.textContent = ""; el.textContent = "";
// // OneBSpace (OneCAD Simulation): the failure message names the fork ["FieldCraft could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
["OneCAD Simulation could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
var p = document.createElement("p"); var p = document.createElement("p");
p.textContent = t; p.textContent = t;
el.appendChild(p); el.appendChild(p);
@@ -61,15 +64,5 @@
addEventListener("unhandledrejection", function (e) { fail(String(e.reason && e.reason.message || e.reason)); }); addEventListener("unhandledrejection", function (e) { fail(String(e.reason && e.reason.message || e.reason)); });
})(); })();
</script> </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> </body>
</html> </html>
-3
View File
@@ -26,9 +26,6 @@ mod storage;
mod usage; mod usage;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod web; mod web;
// OneBSpace (OneCAD Simulation): Odoo Documents host integration
#[cfg(target_arch = "wasm32")]
mod services_odoo;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
fn main() { 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(), web_options(),
Box::new(|cc| { Box::new(|cc| {
let inbox = Inbox::default(); let inbox = Inbox::default();
let services = services(inbox.clone(), cc.egui_ctx.clone()); let mut app = ModelApp::new(Session::default()).with_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);
// Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md). // Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md).
if let Some(host) = crate::usage::host() { if let Some(host) = crate::usage::host() {
app = app.with_usage(host); app = app.with_usage(host);
@@ -121,7 +118,7 @@ pub fn start() {
el.remove(); 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. // Best effort: the console has the message too.
let _ = el.class_list().add_1("failed"); let _ = el.class_list().add_1("failed");
for line in 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") { if let Ok(p) = document.create_element("p") {
p.set_text_content(Some(line)); p.set_text_content(Some(line));
@@ -230,8 +227,6 @@ impl Shell {
let mut paths = Vec::with_capacity(batch.len()); let mut paths = Vec::with_capacity(batch.len());
for (name, read) in batch { for (name, read) in batch {
let path = page_path(&name); 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())) { match read.and_then(|bytes| fieldcraft_io::fs::insert(Path::new(&path), bytes).map_err(|e| e.to_string())) {
Ok(()) => paths.push(path), Ok(()) => paths.push(path),
Err(e) => self.app.set_status(format!("{name}: {e}"), true), Err(e) => self.app.set_status(format!("{name}: {e}"), true),
@@ -262,9 +257,6 @@ impl Shell {
} }
Err(e) => log::warn!("autosave: {e}"), 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) { } else if let Err(e) = download(&path, &bytes) {
self.app.set_status(format!("{}: the download did not start ({e})", path.display()), true); 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 { impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.open_arrived(); 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.app.logic(ctx);
self.autosave(ctx); self.autosave(ctx);
if self.app.now - self.sent_at >= crate::usage::INTERVAL { 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"); crash::phase("window");
let options = eframe::NativeOptions { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
// OneBSpace (OneCAD Simulation): the window title names the fork (the APP_ID, which .with_title("FieldCraft")
// the packaged .desktop entries and the bundle id match, stays upstream).
.with_title("OneCAD Simulation")
.with_app_id(APP_ID) .with_app_id(APP_ID)
.with_icon(app_icon()) .with_icon(app_icon())
.with_inner_size([1400.0, 900.0]) .with_inner_size([1400.0, 900.0])
@@ -267,8 +265,7 @@ fn main() -> eframe::Result {
..Default::default() ..Default::default()
}; };
eframe::run_native( eframe::run_native(
// OneBSpace (OneCAD Simulation): the app name in the window title. "FieldCraft",
"OneCAD Simulation",
options, options,
Box::new(move |cc| { Box::new(move |cc| {
crash::phase("app setup"); 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. /// The longest link a draft opens: GitHub refuses new-issue links much past 8 KB.
pub const MAX_URL: usize = 7000; pub const MAX_URL: usize = 7000;
/// The session file a draft attaches. /// The session file a draft attaches.
// OneBSpace (OneCAD Simulation): the attachment name carries the fork's mark; the internal API pub const SESSION_FILE: &str = "fieldcraft-session.json";
// name (`SESSION_FILE`) stays upstream.
pub const SESSION_FILE: &str = "onecadsim-session.json";
/// The model file a draft attaches. /// The model file a draft attaches.
pub const MODEL_FILE: &str = "model.fieldcraft"; pub const MODEL_FILE: &str = "model.fieldcraft";
/// Backtrace frames a crash's draft quotes. /// 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 crash = input.crash.as_ref();
let mut title = input.title.trim().to_string(); let mut title = input.title.trim().to_string();
if title.is_empty() { 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) { 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(), Some(_) => "Internal error".to_string(),
None => match input.observed.trim().lines().next().filter(|l| !l.is_empty()) { None => match input.observed.trim().lines().next().filter(|l| !l.is_empty()) {
Some(line) => format!("Bug: {}", line.chars().take(100).collect::<String>()), Some(line) => format!("Bug: {}", line.chars().take(100).collect::<String>()),
@@ -227,16 +224,14 @@ pub fn draft(input: &Input, version: &str) -> Draft {
} else { } else {
let text = serde_json::to_string_pretty(script).unwrap_or_default(); let text = serde_json::to_string_pretty(script).unwrap_or_default();
evidence.push_str(&format!( evidence.push_str(&format!(
// OneBSpace (OneCAD Simulation): the fork's name in the evidence text. "\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"
"\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"
)); ));
attachments.insert(0, Attachment { name: SESSION_FILE.into(), bytes: text.clone().into_bytes(), what: "the session's commands".into() }); attachments.insert(0, Attachment { name: SESSION_FILE.into(), bytes: text.clone().into_bytes(), what: "the session's commands".into() });
clipboard = Some(text); clipboard = Some(text);
} }
} }
if model.is_some() { 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 FieldCraft saved it to).\n"));
evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder OneCAD Simulation saved it to).\n"));
} }
if url.is_empty() || model.is_some() { if url.is_empty() || model.is_some() {
let mut f = fields(evidence.clone()); 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 { pub const BUG_REPORT: CommandSpec = CommandSpec {
id: "diagnostics.bug_report", id: "diagnostics.bug_report",
label: "Report a Bug", label: "Report a Bug",
// OneBSpace (OneCAD Simulation): the draft stays local — no GitHub issue link is opened; 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"#,
// 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"#,
form: Some(super::forms::BUG_REPORT), form: Some(super::forms::BUG_REPORT),
kind: CommandKind::Session, kind: CommandKind::Session,
requires: &[], requires: &[],
+1 -2
View File
@@ -15,8 +15,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const CHECK: CommandSpec = CommandSpec { pub const CHECK: CommandSpec = CommandSpec {
id: "app.update.check", id: "app.update.check",
label: "Check for Updates", 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 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"#,
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"#,
form: Some(&[]), form: Some(&[]),
kind: CommandKind::Session, kind: CommandKind::Session,
requires: &[], 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. // Outside any command: what the journal knows has to do.
let extra = from_journal(read_live_journal(state), None); let extra = from_journal(read_live_journal(state), None);
if let Some(id) = write_report(state, "panic", Some(&p), extra) { if let Some(id) = write_report(state, "panic", Some(&p), extra) {
// OneBSpace (OneCAD Simulation): the fork's name on the console. eprintln!("FieldCraft: internal error; crash report {}", state.dir.join(&id).display());
eprintln!("OneCAD Simulation: internal error; crash report {}", state.dir.join(&id).display());
if let Ok(mut j) = state.journal.try_lock() { if let Ok(mut j) = state.journal.try_lock() {
j.record(&json!({ "reported": id })); j.record(&json!({ "reported": id }));
} }
@@ -891,9 +890,7 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
let p = match &native { let p = match &native {
Some(n) => native_panic(n, &was, &when, &during, &what), Some(n) => native_panic(n, &was, &when, &during, &what),
None => Panic { None => Panic {
// OneBSpace (OneCAD Simulation): the fork's name in the crash message (shown in the message: format!("FieldCraft {was} (started {when}) ended without a clean exit {during}"),
// crash question and in the report the user copies).
message: format!("OneCAD Simulation {was} (started {when}) ended without a clean exit {during}"),
location: match (&starting, &doing) { location: match (&starting, &doing) {
(Some((p, _)), _) => format!("unclean exit during startup: {p}"), (Some((p, _)), _) => format!("unclean exit during startup: {p}"),
(None, c) => format!("unclean exit in {}", c.as_deref().unwrap_or("(between commands)")), (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}")); backtrace.push(format!("on thread {t}"));
} }
Panic { Panic {
// OneBSpace (OneCAD Simulation): the fork's name in the native-crash message. message: format!("FieldCraft {was} (started {when}) crashed with {name} ({code}) in {at} {during}"),
message: format!("OneCAD Simulation {was} (started {when}) crashed with {name} ({code}) in {at} {during}"),
location: format!("native exception in {at}"), 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}")), thread: native.get("thread").and_then(Value::as_u64).map_or_else(|| "(unknown)".into(), |t| format!("native thread {t}")),
backtrace, 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 //! **The table** ([`Dimension`], [`Unit`], [`Quantity`], [`DisplaySystem`]) is the one place
//! system they are in, so the check can warn when a magnitude is implausible for it (a steel //! FieldCraft writes down a conversion factor. Each [`Quantity`] has a dimension and an SI unit;
//! modulus of 210 000 in a metre–pascal model, a density of 7850 in a millimetre–tonne one), and //! a [`Unit`] parses expressions such as `MPa`, `t/mm³` or `W/(m·K)` to a dimension and a factor
//! so writers can say which system a deck is in. //! 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}; 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 /// FieldCraft's mark: the application icon's fringe cube (`assets/app-icon/icon-small.svg`, after
// `assets/app-icon/icon-small.svg`) is replaced by a painted text wordmark, so the bar names /// `xtask/src/app_icon.rs`) drawn in code without its tile or streamline: each visible face split
// the fork in every width variant (a narrow bar loses the menus, not the app's own name). /// into small cells coloured by the fringe band at the cell's centre, shaded per face.
pub const APP_NAME: &str = "OneCAD Simulation"; 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. /// Where the title bar's parts go.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct BarLayout { 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, 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], pub quick: [Rect; 4],
/// Solve, then Search, Theme and Help: at the right end, before the version. /// Solve, then Search, Theme and Help: at the right end, before the version.
pub solve: Rect, pub solve: Rect,
@@ -80,23 +112,19 @@ pub struct BarLayout {
pub tab_room: (f32, f32), pub tab_room: (f32, f32),
/// The version's right edge. /// The version's right edge.
pub version_right: f32, 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, pub compact: bool,
} }
/// The title bar's layout in `bar`. `traffic_lights`: the macOS window buttons sit over its left /// 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 /// 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; /// 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 pub fn bar_layout(bar: Rect, traffic_lights: bool, captions: f32, version_w: f32) -> BarLayout {
/// the wordmark, in every width variant).
pub fn bar_layout(bar: Rect, traffic_lights: bool, captions: f32, version_w: f32, name_w: f32) -> BarLayout {
let left = bar.left() + if traffic_lights { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }; let left = bar.left() + if traffic_lights { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
let compact = bar.right() - left < 760.0; let compact = bar.right() - left < 760.0;
let cy = bar.center().y; let cy = bar.center().y;
let button = |x: f32| Rect::from_center_size(pos2(x + BUTTON / 2.0, cy), Vec2::splat(BUTTON)); 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 let x0 = left + if compact { 44.0 } else { 132.0 };
// 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 quick = [0.0, 1.0, 2.0, 3.0].map(|i| button(x0 + i * STRIDE)); 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 version_right = bar.right() - 12.0 - captions;
let tools_end = version_right - version_w - 14.0; 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_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)); let solve = Rect::from_min_size(pos2(tools[0].left() - 10.0 - solve_w, cy - 12.0), vec2(solve_w, 24.0));
BarLayout { BarLayout {
mark: pos2(left + 14.0, cy), mark: pos2(left + 20.0, cy),
name: (!compact).then(|| pos2(left + 36.0, cy)),
quick, quick,
solve, solve,
tools, 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 captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
let version = format!("v{}", crate::VERSION); 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; 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 lay = bar_layout(r, app.integrated_titlebar && !fullscreen(ui.ctx()), captions, version_w);
let name_w = ui.painter().layout_no_wrap(APP_NAME.to_string(), FontId::proportional(14.0), t.app_bar_text).size().x; mark(ui.painter(), lay.mark);
let lay = bar_layout(r, app.integrated_titlebar && !fullscreen(ui.ctx()), captions, version_w, name_w); if let Some(at) = lay.name {
// OneBSpace (OneCAD Simulation): painted wordmark instead of the drawn mark + name. ui.painter().text(at, Align2::LEFT_CENTER, "FieldCraft", FontId::proportional(14.0), t.app_bar_text);
ui.painter().text(lay.mark, Align2::LEFT_CENTER, APP_NAME, FontId::proportional(14.0), t.app_bar_text); }
let (undo, redo) = app.session.undo_depth(); let (undo, redo) = app.session.undo_depth();
let busy = app.busy().is_some(); let busy = app.busy().is_some();
let file_open = app.ui.menu.as_deref() == Some("file"); 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("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.label(format!("S or {} searches every command; the command line at the bottom runs `id {{json}}`.", shortcut("K")));
ui.separator(); ui.separator();
// OneBSpace (OneCAD Simulation): upstream's "Report a Bug…", "Check for Updates" and let report = ui
// "Reinstall FieldCraft…" items are gone — nothing here reaches GitHub or an update host. .button("Report a Bug…")
// A report is drafted locally ("Report It…" after a crash, Help › Crash Reports) and copied .on_hover_text("Draft a GitHub issue with the version, the OS and the last error; nothing is sent until you submit it")
// out by hand; the version label names the fork. .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 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(); 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!("FieldCraft {}", crate::VERSION)).color(t.text_dim));
ui.label(egui::RichText::new(format!("{} {}", APP_NAME, crate::VERSION)).color(t.text_dim)); if report {
if crashes { app.report_bug(None);
} else if crashes {
app.open_crash_reports(); app.open_crash_reports();
} else if check {
app.check_for_updates();
} else if reinstall {
app.reinstall();
} else if settings { } else if settings {
app.open_settings(); app.open_settings();
} }
@@ -334,16 +377,13 @@ mod tests {
/// at the very left in full screen (where there are none). /// at the very left in full screen (where there are none).
#[test] #[test]
fn the_title_bar_keeps_solve_right_and_drops_the_traffic_light_inset_in_full_screen() { 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 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 windowed = bar_layout(bar, true, 0.0, 30.0);
let full = bar_layout(bar, false, 0.0, 30.0, name_w); let full = bar_layout(bar, false, 0.0, 30.0);
assert_eq!(windowed.mark.x - full.mark.x, crate::titlebar::TRAFFIC_LIGHTS); 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] { 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.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.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())); 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. // The right group does not move with full screen; custom caption buttons push it left.
assert_eq!(windowed.solve, full.solve); 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); 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. // 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, name_w); let narrow = bar_layout(Rect::from_min_size(egui::Pos2::ZERO, vec2(700.0, HEIGHT)), true, 0.0, 30.0);
assert!(narrow.compact && narrow.quick[3].right() < narrow.solve.left(), "{narrow:?}"); assert!(narrow.compact && narrow.name.is_none() && narrow.quick[3].right() < narrow.solve.left());
// 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:?}");
} }
} }
+1 -3
View File
@@ -77,9 +77,7 @@ pub const DEFAULT_PRESET: &str = "fieldcraft";
pub const NAV_PRESETS: [NavPreset; 7] = [ pub const NAV_PRESETS: [NavPreset; 7] = [
NavPreset { NavPreset {
id: DEFAULT_PRESET, id: DEFAULT_PRESET,
// OneBSpace (OneCAD Simulation): the preset's label names the fork (its `id`, which label: "FieldCraft",
// preferences store, stays upstream).
label: "OneCAD Simulation",
help: "Left drag does the navigation bar's mode (orbit, pan or zoom); right, middle or Shift+left drag pans", help: "Left drag does the navigation bar's mode (orbit, pan or zoom); right, middle or Shift+left drag pans",
reverse_zoom: false, reverse_zoom: false,
bindings: &[bind(Left, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Pan), bind(Middle, NavKeys::NONE, Pan)], 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::ModelApp;
use crate::files::Prompt; use crate::files::Prompt;
/// What is sent, event by event. (OneBSpace (OneCAD Simulation): upstream's PRIVACY_URL linked /// What is sent, event by event.
/// 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.)
pub const PRIVACY_URL: &str = "https://github.com/tangency-labs/fieldcraft/blob/main/docs/privacy.md"; 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.) /// The question, as the first run asks it.
pub const QUESTION: &str = "Help improve OneCAD Simulation by sharing anonymous usage: which tools you use, nothing from your models."; 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, \ 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 \ 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."; 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); let t = crate::theme::Tokens::get(app.ui.dark);
match app.prompt { match app.prompt {
Some(Prompt::Consent) => { Some(Prompt::Consent) => {
// OneBSpace (OneCAD Simulation): the privacy note is plain text — the upstream let (mut share, mut decline, mut link, mut updates) = (false, false, false, None);
// GitHub link (PRIVACY_URL) is not rendered as a link and never opened. crate::frame::window(ctx, "Help Improve FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_consent")).show(ctx, |ui| {
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| {
ui.label(QUESTION); ui.label(QUESTION);
ui.add_space(4.0); ui.add_space(4.0);
ui.label(RichText::new(DETAIL).color(t.text_dim).size(11.0)); ui.label(RichText::new(DETAIL).color(t.text_dim).size(11.0));
ui.add_space(2.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("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() { if app.updates_available() {
ui.add_space(4.0); ui.add_space(4.0);
updates = update_check(ui, &t, app.prefs.update_check); 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(); decline = ui.button("Don't Share").clicked();
}); });
}); });
if link {
app.open_link(ctx, PRIVACY_URL);
}
if let Some(on) = updates { if let Some(on) = updates {
app.set_update_prefs(on, None); app.set_update_prefs(on, None);
} }
@@ -178,20 +176,15 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
Some(Prompt::Settings) => { Some(Prompt::Settings) => {
let available = app.usage_available(); let available = app.usage_available();
let (mut sharing, mut automation) = (app.prefs.usage_sharing == Some(true), app.prefs.usage_automation); 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 let (mut close, mut link, mut updates, mut unskip, mut check) = (false, false, None, false, false);
// browser. The dialog never opens PRIVACY_URL.
let (mut close, mut updates, mut unskip, mut check) = (false, None, false, false);
crate::frame::window(ctx, "Settings", crate::frame::Width::Normal).id(egui::Id::new("fc_settings")).show(ctx, |ui| { crate::frame::window(ctx, "Settings", crate::frame::Width::Normal).id(egui::Id::new("fc_settings")).show(ctx, |ui| {
if app.updates_available() { if app.updates_available() {
crate::frame::menu_heading(ui, &t, "UPDATES"); crate::frame::menu_heading(ui, &t, "UPDATES");
updates = update_check(ui, &t, app.prefs.update_check); updates = update_check(ui, &t, app.prefs.update_check);
ui.horizontal(|ui| { ui.horizontal(|ui| {
// OneBSpace (OneCAD Simulation): the tooltip no longer points at Help › check = ui.button("Check Now").on_hover_text("Help › Check for Updates").clicked();
// 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();
if !app.prefs.update_skip.is_empty() { if !app.prefs.update_skip.is_empty() {
// OneBSpace (OneCAD Simulation): the fork's name in the skip note. ui.label(RichText::new(format!("Skipping FieldCraft {}", app.prefs.update_skip)).color(t.text_dim).size(11.0));
ui.label(RichText::new(format!("Skipping OneCAD Simulation {}", app.prefs.update_skip)).color(t.text_dim).size(11.0));
unskip = ui.small_button("Offer It Again").clicked(); 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(RichText::new(DETAIL).color(t.text_dim).size(11.0));
ui.label( ui.label(
// OneBSpace (OneCAD Simulation): the report flow is local-only now (a draft RichText::new("Crash reports and the log stay on this computer; Help › Report a Bug sends only what you submit.")
// 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.")
.color(t.text_dim) .color(t.text_dim)
.size(11.0), .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); ui.add_space(6.0);
close = ui.button("Close").clicked(); close = ui.button("Close").clicked();
}); });
@@ -233,6 +224,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
if check { if check {
app.check_for_updates(); app.check_for_updates();
} }
if link {
app.open_link(ctx, PRIVACY_URL);
}
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None; app.prompt = None;
} }
@@ -247,13 +241,11 @@ fn update_check(ui: &mut egui::Ui, t: &crate::theme::Tokens, on: bool) -> Option
let changed = ui let changed = ui
.checkbox(&mut checked, "Check for updates automatically") .checkbox(&mut checked, "Check for updates automatically")
.on_hover_text( .on_hover_text(
// OneBSpace (OneCAD Simulation): the fork's name in the tooltip (desktop-only check). "At start-up, at most once a day, FieldCraft asks GitHub Releases for the latest version. Nothing installs until you choose Install.",
"At start-up, at most once a day, OneCAD Simulation asks GitHub Releases for the latest version. Nothing installs until you choose Install.",
) )
.changed(); .changed();
ui.label( 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 FieldCraft version reach GitHub); off means no check.")
RichText::new("The check sends only the request (your IP address and the OneCAD Simulation version reach GitHub); off means no check.")
.color(t.text_dim) .color(t.text_dim)
.size(11.0), .size(11.0),
); );
+52 -53
View File
@@ -1,13 +1,13 @@
//! Help › Crash Reports, "Report It…" after a crash ("OneCAD Simulation closed unexpectedly — //! Help › Report a Bug, and the question the window asks after a crash ("FieldCraft closed
//! Report it"), and the Report a Bug form they open. //! unexpectedly — Report it").
//! //!
// OneBSpace (OneCAD Simulation): upstream opened the filled GitHub issue form in the browser; //! The form shows exactly what the GitHub issue form will be filled with
// 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
// ([`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
// 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
// built the model, **Include the model file** the model as well. **Copy report** puts it all on //! until the user clicks **Open GitHub Issue**, which opens the filled form in their browser (they
// the clipboard (nothing opens, nothing is sent); attachments are saved to a local folder to //! submit it there, or not); files too big for the link are saved to a folder to attach from, and
// attach from wherever the user files their reports. //! a long session is also put on the clipboard.
use egui::RichText; use egui::RichText;
use fieldcraft_engine::bug_report::{self, Draft, Input}; 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 /// Open the draft in the browser: attachments saved to a folder of their own, the session on
// form is opened (upstream opened the draft's ISSUE_URL). The draft's text goes to the /// the clipboard when it is too long for the link.
// clipboard and any attachments stay in the local bug-reports folder to attach by hand. fn send_report(&mut self, ctx: &egui::Context, draft: &Draft) {
fn copy_report(&mut self, ctx: &egui::Context, draft: &Draft) { let mut saved = None;
let mut text = draft.title.clone(); if !draft.attachments.is_empty() {
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 {
match bug_report::save_attachments(draft) { match bug_report::save_attachments(draft) {
Ok(dir) => format!("Report copied to the clipboard: attach the files in {}.", dir.display()), Ok(dir) => saved = Some(dir),
Err(e) => format!("Report copied to the clipboard (the files to attach could not be saved: {e})."), 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.prompt = None;
self.report = None; self.report = None;
} }
@@ -218,11 +226,10 @@ fn size(bytes: usize) -> String {
} }
fn kind_label(kind: &str) -> &'static str { fn kind_label(kind: &str) -> &'static str {
// OneBSpace (OneCAD Simulation): the fork's name in the crash headings.
match kind { match kind {
"unclean_exit" => "OneCAD Simulation closed unexpectedly", "unclean_exit" => "FieldCraft closed unexpectedly",
"command_panic" => "A command hit an internal error", "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); 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| { 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() { ui.label(match first.kind.as_str() {
// OneBSpace (OneCAD Simulation): the fork's name in the crash question. "unclean_exit" => "FieldCraft closed unexpectedly the last time it ran.",
"unclean_exit" => "OneCAD Simulation closed unexpectedly the last time it ran.", _ => "FieldCraft hit an internal error the last time it ran.",
_ => "OneCAD Simulation hit an internal error the last time it ran.",
}); });
ui.label(RichText::new(&first.message).color(t.text_dim)); ui.label(RichText::new(&first.message).color(t.text_dim));
if reports.len() > 1 { if reports.len() > 1 {
@@ -248,10 +254,8 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
} }
ui.add_space(4.0); ui.add_space(4.0);
ui.label( ui.label(
// OneBSpace (OneCAD Simulation): reporting drafts locally — no GitHub issue
// is opened; the user copies the report into their own tracker.
RichText::new( 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), .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."); ui.label("No crash reports are saved on this computer.");
} else { } else {
ui.label( ui.label(
// OneBSpace (OneCAD Simulation): reporting drafts locally — no GitHub RichText::new("Saved on this computer, newest first. Reporting one opens a GitHub issue you review first; nothing is sent until you submit it.")
// 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.")
.color(t.text_dim), .color(t.text_dim),
); );
ui.add_space(4.0); ui.add_space(4.0);
@@ -342,13 +344,13 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
app.prompt = None; app.prompt = None;
return; return;
}; };
let (mut copy, mut close) = (false, false); let (mut send, mut close) = (false, false);
let commands = form.input.commands.len(); 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| { crate::frame::window(ctx, "Report a Bug", crate::frame::Width::Wide).id(egui::Id::new("fc_report_bug")).show(ctx, |ui| {
ui.label( ui.label(
// OneBSpace (OneCAD Simulation): the report stays in the workspace — it is RichText::new(
// copied to the clipboard, never opened as a GitHub issue in the browser. "This opens a GitHub issue in your browser, filled in with what is shown below. Nothing is sent until you submit it there.",
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), .color(t.text_dim),
); );
if let Some(p) = &form.problem { if let Some(p) = &form.problem {
@@ -384,12 +386,11 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let draft = form.draft().clone(); let draft = form.draft().clone();
ui.add_space(6.0); ui.add_space(6.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
// OneBSpace (OneCAD Simulation): "Copy report" instead of "Open GitHub Issue". send = ui.button("Open GitHub Issue").clicked();
copy = ui.button("Copy report").clicked();
close = ui.button("Cancel").clicked(); close = ui.button("Cancel").clicked();
}); });
ui.add_space(4.0); 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| { egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
ui.label(RichText::new(format!("Title: {}", draft.title)).monospace().size(11.0)); ui.label(RichText::new(format!("Title: {}", draft.title)).monospace().size(11.0));
for (k, v) in &draft.fields { 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)); 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() { if draft.clipboard.is_some() {
// OneBSpace (OneCAD Simulation): the session is copied with the report. ui.label(RichText::new("The session goes on the clipboard too, to paste.").size(11.0).color(t.text_dim));
ui.label(RichText::new("The session is copied with the report.").size(11.0).color(t.text_dim));
} }
}); });
}); });
}); });
let draft = form.draft().clone(); let draft = form.draft().clone();
app.report = Some(form); app.report = Some(form);
// OneBSpace (OneCAD Simulation): the copied report never touches the network. if send {
if copy { app.send_report(ctx, &draft);
app.copy_report(ctx, &draft);
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { } else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None; app.prompt = None;
app.report = None; app.report = None;
+16 -22
View File
@@ -123,9 +123,9 @@ impl ModelApp {
self.set_status(format!("Cannot reinstall: {why}"), true); self.set_status(format!("Cannot reinstall: {why}"), true);
return; 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); (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 /// 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) { pub fn install_update(&mut self, ctx: &egui::Context) {
let Some(u) = &mut self.updates else { return }; let Some(u) = &mut self.updates else { return };
let Some(release) = u.available.clone() 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!("FieldCraft {} is not signed", release.version)));
let blocked = u.host.unsupported.clone().or_else(|| (!release.signed()).then(|| format!("OneCAD Simulation {} is not signed", release.version)));
if let Some(why) = blocked { if let Some(why) = blocked {
self.set_status(format!("{why}: opening the download page"), true); self.set_status(format!("{why}: opening the download page"), true);
self.open_link(ctx, &release.url); self.open_link(ctx, &release.url);
return; 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())); (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. /// 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 Some((s, Finish::Swap(_))) = &u.staged
{ {
let v = s.version.to_string(); 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; return;
} }
@@ -235,16 +234,14 @@ impl ModelApp {
let v = check.latest.version.to_string(); let v = check.latest.version.to_string();
u.available = Some(check.latest); u.available = Some(check.latest);
if manual { if manual {
// OneBSpace (OneCAD Simulation): the fork's name in the status. self.set_status(format!("FieldCraft {v} is available"), false);
self.set_status(format!("OneCAD Simulation {v} is available"), false);
} }
} }
Ok(check) => { Ok(check) => {
// Nothing newer (or skipped): a notice from an earlier check goes. // Nothing newer (or skipped): a notice from an earlier check goes.
u.available = None; u.available = None;
if manual { if manual {
// OneBSpace (OneCAD Simulation): the fork's name in the status. self.set_status(format!("FieldCraft {} is up to date", check.current), false);
self.set_status(format!("OneCAD Simulation {} is up to date", check.current), false);
} }
} }
Err(e) if manual => self.set_status(format!("Could not check for updates: {e}"), true), 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() { if ui.add(egui::Link::new(RichText::new("Reload to update").size(11.5))).on_hover_text(tip).clicked() {
reload(); reload();
} }
// OneBSpace (OneCAD Simulation): the fork's name in the status-bar notice. ui.label(RichText::new("A new version of FieldCraft is available —").size(11.5).color(t.text));
ui.label(RichText::new("A new version of OneCAD Simulation is available —").size(11.5).color(t.text));
ui.separator(); ui.separator();
return; 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(); 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)); 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(); 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!("FieldCraft {} is available —", release.version)).color(t.text));
ui.label(small(&format!("OneCAD Simulation {} is available —", release.version)).color(t.text));
ui.separator(); ui.separator();
if notes { if notes {
app.open_link(ui.ctx(), &release.url); 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 reinstall = staged.version.to_string() == crate::VERSION;
let (mut restart, mut later, mut open) = (false, false, false); 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 FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_update")).show(ctx, |ui| match &finish {
crate::frame::window(ctx, "Update OneCAD Simulation", crate::frame::Width::Normal).id(egui::Id::new("fc_update")).show(ctx, |ui| match &finish {
Finish::Swap(_) => { Finish::Swap(_) => {
ui.label(if reinstall { 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 { } 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( ui.label(
RichText::new(format!("It replaces {}. Unsaved changes are saved or asked about first.", staged.install.describe())) 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.add_space(6.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
restart = ui.button("Restart Now").clicked(); 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 } => { 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(RichText::new(file.display().to_string()).monospace().size(11.0));
ui.label("Install it with your system's package manager:"); ui.label("Install it with your system's package manager:");
let mut text = command.clone(); 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(); tx.send(Event::Checked { manual: true, result: Ok(Check { current: current.clone(), latest: release(crate::VERSION) }) }).unwrap();
a.poll_updates(&ctx); 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"); assert_eq!(a.update_offered(), None, "the earlier offer goes");
tx.send(Event::Checked { manual: false, result: Err("offline".into()) }).unwrap(); tx.send(Event::Checked { manual: false, result: Err("offline".into()) }).unwrap();
a.status = None; 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 licences;
mod skill; mod skill;
mod test; mod test;
mod unit_factors;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode}; use std::process::{Command, ExitCode};
@@ -53,6 +54,8 @@ commands:
and the flags CI plans its jobs with: everything (manifest, lock file, and the flags CI plans its jobs with: everything (manifest, lock file,
toolchain, xtask or workflows changed), solver (the heavy tests or the oracle toolchain, xtask or workflows changed), solver (the heavy tests or the oracle
are affected), wasm (a crate the wasm check builds is affected) 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 wasm cargo check every library crate and the web app for wasm32-unknown-unknown
ci [--affected REV] ci [--affected REV]
fmt --check, assets, layers, clippy -D warnings, then the tests 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); c.args(["run", "--release", "-p", affected::ORACLE_PACKAGE, "--bin", "oracle", "--"]).args(&rest);
run(c, "oracle") run(c, "oracle")
} }
Some("unit-factors") => unit_factors::run(&root()),
Some("wasm") => cmd_wasm(), Some("wasm") => cmd_wasm(),
Some("-h" | "--help" | "help") | None => { Some("-h" | "--help" | "help") | None => {
print!("{USAGE}"); 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]);
}
}