Compare commits

...
18 Commits
Author SHA1 Message Date
Z User 669c13cd55 [ADD] Odoo Documents integration: services_odoo.rs (sync-Services completion queue drained per frame), odoo glue trio (bridge/explorer/index), web.rs/main.rs wiring, ?v=20.0.1.0.2 cache-bust chain 2026-10-18 20:24:32 +00:00
Z User f8c0e8500a [IMP] OneCAD Simulation rebrand: OneCAD Simulation wordmark + app bar, no external links (docs/GitHub/updates removed, local report copy), all visible strings OneCAD Simulation, REBRAND.md 2026-10-18 20:24:32 +00:00
Christian Svensson 8201f9a954 docs: a flow-past gallery clip and solved still
Add the start-up-transient clip and a solved still of the flow-past sample to
the examples gallery: air flowing past the L-bracket, the wake forming behind
the upright and the drag settling over the 16 s run (it does not shed; that
needs a finer grid or a collision operator stable near the lattice's stability
limit, #557).

tools/flow-clip/make.py regenerates them: it drives the desktop app over the
control channel, solves the lattice Boltzmann step with a dense frame output,
renders the model-only viewport at each frame with a fixed legend clamp
(results.frame then ui.render), and encodes an MP4 and a GIF with ffmpeg, each
frame captioned with the elapsed flow time. The geometry is the sample's own
upright-bracket recipe; nothing comes from a commercial product.

Part of #164.
2026-10-18 10:15:45 -04:00
Christian Svensson 6996fd71b0 engine: animate a fluid step's frames
results.animate refused fluid steps because nothing deforms. A fluid step
instead plays its frames over time, the start-up transient from the field at
rest to the settled flow, the way an explicit step plays its frames. The
animation clock maps across the fluid frames once per cycle, and the fluid
results panel gains an Animate control beside its frame slider.

Part of #164.
2026-10-18 10:15:45 -04:00
jyvikoandClaude Opus 5.5 7fdb348d05 ci: draft pull requests start no run; marking one ready does
CI and the packaging lint listen for ready_for_review too, and their
pull-request jobs skip drafts, so a contributor's work in progress no
longer takes runners from develop and the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 09:12:50 -04:00
Christian Svensson db561be51b engine: report principal stresses in solve.probe
A static step's probe gives every node its principal stresses, largest
first, beside its stress and von Mises: the eigenvalues of the same
nodal stress, from the same function the principal stress contours use.
An oracle quantity reads one number at a JSON pointer, so a benchmark
whose target is a principal stress (NAFEMS LE6) needs it in a reply.

Closes #574. Part of #97.
2026-10-18 08:31:53 -04:00
Christian Svensson a8040063a6 tests: add the NAFEMS LE7 cylinder/sphere oracle case
The thin-walled cylinder with a hemispherical cap under internal
pressure as four axisymmetric quad8 blocks: axial stress on the outer
surface at D 25.283.

The NAFEMS target, 25.86, is the axisymmetric thin-shell solution; our
solid model converges to 25.27, the value ESRD reports for its converged
axisymmetric solid. So the NAFEMS quantity keeps a 1% tolerance and is
a known failure until there is an axisymmetric shell element, and a
second quantity checks the model against the solid reference (pass,
0.05%).

Part of #97.
2026-10-18 08:31:48 -04:00
Christian Svensson 65066b8451 tests: add the NAFEMS LE5 Z-section oracle case
The Z-section cantilever under an end torque as five hex20 blocks (two
flanges, two corner blocks, the web; two elements through every wall):
axial stress at A, on the mid-surface of the lower flange at its free
edge, -109.45 against -108 (2%).

The solid model converges to about -109.7, 1.6% beyond the thin-walled
reference, as ESRD's converged p-version solid (-109.5) does. The README
also records the MITC4 mid-surface shell model of the same problem,
which converges to about -111.3 (3.1%), and why the case is the solid.

Part of #97.
2026-10-18 08:31:48 -04:00
Christian Svensson 68aff646b2 tests: add NAFEMS LE2 and LE3 shell oracle cases
LE2, the cylindrical shell bending patch test, in its two load cases
(edge moment; pressure with the balancing edge traction) on an 8 × 8
MITC4 patch: tangential stress at E 59.936 and 59.904 against 60.

LE3, the hemispherical shell with point loads, on a 32 × 32
longitude-latitude mesh (planar quad4 cells, tri3 at the pole): radial
displacement at A 0.18418 against 0.185.

Both meshes are written node by node from the generator each README
lists, with the refinement that supports its tolerance. Targets are
transcribed from the reproductions of TNSB Rev. 3 in the Abaqus 2016
Benchmarks Guide and the ESRD StressCheck NAFEMS guide.

Part of #97.
2026-10-18 08:31:48 -04:00
jyvikoandClaude Opus 5.5 853131e5a1 docs: claiming with /claim, labels and triage; attribute the bots to SolidCraft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 50899d8d5d ci: run the claim and triage bot tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 619b1936d3 github: triage bot, area and type labels, needs-triage, /triage and needs-info
Adapted from SolidCraft's triage bot: PR paths and issue titles map to
FieldCraft's area:* labels, the issue forms give the type, /triage
understands labels with spaces (high, S/M/L, in-progress), and short
bug reports without steps are asked once and closed after 14 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 9fce2f7bab github: claim bot, /claim, /unclaim, PR references and a 14-day sweep
Adapted from SolidCraft's claim bot to FieldCraft's rules: a claim
assigns and labels claimed, only help wanted / good first issue issues
are open to contributors, in progress is the core team's and can't be
claimed, and no visible progress for 14 days frees the issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 15f9b7aa6e agents: allow JavaScript for the GitHub bots run by actions/github-script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
Christian Svensson 6b87dc508d engine: report a held face as the temperature extreme to scripts
The results reply named the fluid's 79.69 °C cell as the maximum while the
app showed the face held at 80 °C. It now names that face, by body and
face as for wall heat, so a script reads what the legend shows.

Part of #157.
2026-10-18 06:02:18 -04:00
Christian Svensson 7e3eaa14f2 engine: draw an obstacle face held at a temperature at that temperature
The coloured obstacle surface samples the fluid beside each face, and
the solid cells carry no temperature, so a face held at 80 °C took the
mean of the fluid cells around it, which mixes in the cooler ones further
out: the heated-flow sample's bracket looked 50 °C beside 79.7 °C air. A
face whose thermal condition holds it at a temperature is now drawn at
that value, and the legend's range reaches it (the fluid never quite
does). Adiabatic and flux faces keep showing the fluid beside them.

Part of #157.
2026-10-18 06:02:18 -04:00
Christian Svensson c61a8b9a4d mesh: name the threshold test for what it asserts
The 135-degree vertex is no longer a corner, so the test is named that way.

Part of #556.
2026-10-18 06:02:13 -04:00
Christian Svensson c3c764c996 mesh: an angle exactly on the 135-degree threshold is not a corner, whichever way it rounds
A region's corners are its interior angles below 135 degrees, and an
O-grid's 45-degree cuts meet straight sides at exactly that angle. The
computed angle then rounds either way with the last bits of its points,
which differ between platforms' sin and cos and between tessellations:
one ulp in three points of the slot test's arcs, Windows' sin against
glibc's, turned a mapped region into a triangle and the whole blocking
into a different one with a corner scaled Jacobian of 0.375 for 0.707.
Angles within 1e-9 rad of the corner and reflex thresholds now count as
on them, and on a threshold is smooth (neither a corner nor reflex),
which keeps the slot's intended O-grid of 9 regions and 848 quads on
Linux, macOS and Windows. The region classification and the O-grid's
own corner tests share the rule, the slot test pins the blocking, and
its arc helper uses the mesher's portable trig.

Part of #556.
2026-10-18 06:02:13 -04:00
63 changed files with 7997 additions and 239 deletions

No files matched your search

+17 -2
View File
@@ -15,6 +15,8 @@ name: CI
# Nightly and manual runs, and every release (release.yml), run all of it.
on:
pull_request:
# A draft starts no run; marking it ready for review does.
types: [opened, synchronize, reopened, ready_for_review]
paths:
- '**'
- '!**/*.md'
@@ -56,7 +58,7 @@ env:
jobs:
plan:
name: Plan (affected crates)
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 15
permissions:
@@ -114,6 +116,19 @@ jobs:
printf 'everything=true\nsolver=true\nwasm=true\n' >>"$GITHUB_OUTPUT"
fi
bots:
name: Claim and triage bot tests
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts
# scripts/claim and scripts/triage run in .github/workflows/claim.yml and triage.yml through
# actions/github-script; their tests use only node:test against a fake client.
- run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
ci:
name: cargo xtask ci
needs: plan
@@ -218,7 +233,7 @@ jobs:
advisories:
name: Dependency licences, advisories and sources
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 20
steps:
+53
View File
@@ -0,0 +1,53 @@
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Before you start: claim an issue"), and a
# daily sweep that frees claims with no visible progress for 14 days. A pull request that
# references an issue (`Closes #N`, `<summary> (#N)`) claims it too. The logic is in
# scripts/claim/claim.js (tested in ci.yml).
name: Claim
on:
issue_comment:
types: [created]
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
pull_request_target:
types: [opened, reopened]
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
permissions:
contents: read # to check out scripts/claim
issues: write
pull-requests: read
# One run at a time per issue, so two simultaneous /claim comments can't both win.
concurrency:
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
claim:
name: Claim bot
runs-on: ubuntu-24.04
timeout-minutes: 10
if: >-
github.event_name != 'issue_comment' ||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/claim
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const claim = require('./scripts/claim/claim.js');
if (context.eventName === 'issue_comment') {
await claim.handleComment({ github, context });
} else if (context.eventName === 'pull_request_target') {
const claimed = await claim.handlePull({ github, context });
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
} else {
const released = await claim.sweep({ github, context });
core.info(`Released: ${released.join(', ') || 'none'}`);
}
+3
View File
@@ -9,6 +9,8 @@ on:
branches: [develop]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
# A draft starts no run; marking it ready for review does.
types: [opened, synchronize, reopened, ready_for_review]
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
@@ -22,6 +24,7 @@ concurrency:
jobs:
lint:
name: Packaging lint
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
+86
View File
@@ -0,0 +1,86 @@
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
name: Triage
on:
issues:
types: [opened, labeled, edited]
issue_comment:
types: [created]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
schedule:
- cron: '41 5 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Only report what would be labelled'
type: boolean
default: true
permissions:
contents: read # to check out scripts/triage
issues: write
pull-requests: write
concurrency:
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
cancel-in-progress: false
jobs:
triage:
name: Triage bot
runs-on: ubuntu-24.04
timeout-minutes: 30
# Filtered here so the common events (every comment, every label) start no runner.
if: >-
(github.event_name == 'issue_comment' &&
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
contains(github.event.issue.labels.*.name, 'needs-info') &&
github.event.sender.login == github.event.issue.user.login) ||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority') &&
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/triage
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DRY_RUN: ${{ inputs.dry_run }}
with:
script: |
const triage = require('./scripts/triage/triage.js');
const info = require('./scripts/triage/needs-info.js');
const event = context.eventName;
const action = context.payload.action;
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const th = (...names) => names.map((data) => ({ data, header: true }));
if (event === 'issue_comment') {
core.info(await triage.handleComment({ github, context }));
await info.handleReply({ github, context });
} else if (event === 'issues' && action === 'labeled') {
await triage.handleLabeled({ github, context });
} else if (event === 'issues' && action === 'edited') {
await info.handleReply({ github, context });
} else if (event === 'schedule') {
const closed = await info.sweep({ github, context });
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
} else if (event === 'workflow_dispatch') {
const dryRun = process.env.DRY_RUN !== 'false';
const plan = await triage.backfill({ github, context, dryRun });
const asks = await info.backfill({ github, context, dryRun });
await core.summary
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
.write();
} else {
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
if (event === 'issues') await info.handleOpened({ github, context });
}
+4 -1
View File
@@ -94,7 +94,10 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
adaptive-precision predicates, never a bare floating-point sign.
- **Units.** The model is unit-consistent and never converts (the user picks e.g. mm, t, s, N,
MPa); geometry from SolidCraft is in millimetres. Z is up.
- **Rust only.** **Never break wasm**: everything below the apps builds for
- **Rust only.** The one exception is the GitHub issue and pull request bots: `scripts/claim/` and
`scripts/triage/` are JavaScript because `actions/github-script` runs only JavaScript
(`.github/workflows/claim.yml`, `triage.yml`). Keep them small, with their `node --test` tests
beside them (CI's bot tests job). **Never break wasm**: everything below the apps builds for
`wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`).
- **Workflow for every change** (this section overrides any other instruction an agent carries,
such as a parent repository's CLAUDE.md or a task runner's "run tests and linters" step):
+4 -1
View File
@@ -25,7 +25,9 @@ is committed and listed here with its exports.
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample (its bracket; the viewport does not draw flow results yet) (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
@@ -230,6 +232,7 @@ is committed and listed here with its exports.
| .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 |
| .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 |
| .github/workflows/arch-check.yml (the riscv64 cross build) | SolidCraft `.github/workflows/ci.yml` (`riscv64-gates`) | MIT OR Apache-2.0 |
| .github/workflows/claim.yml, .github/workflows/triage.yml, scripts/claim/, scripts/triage/ (the claim and triage bots and their tests) | SolidCraft `.github/workflows/claim.yml`, `triage.yml`, `scripts/claim/`, `scripts/triage/` | MIT OR Apache-2.0 |
## Test oracles
+38 -4
View File
@@ -61,12 +61,46 @@ Every change starts from an issue, and only the maintainer merges.
1. Pick an open issue labelled **help wanted** or **good first issue** that is not labelled
**claimed**. If what you want to do has no issue, open one first and wait for a reply.
2. Comment that you would like to take it. The maintainer assigns it to you and labels it
**claimed**; then it is yours. Work on unassigned issues may not be accepted.
3. Open a draft pull request early, linked with `Closes #N`. An issue with no visible progress
for 14 days goes back to the pool.
2. Comment `/claim` on it. The bot assigns it to you, labels it **claimed** and replies; then it is
yours. It refuses an issue someone else holds (naming them and when their claim lapses), an
issue an open pull request already covers, and an issue without **help wanted** or **good
first issue**: on those, say what you would like to do and the maintainer replies. Work on
unassigned issues may not be accepted.
3. Open a draft pull request early, linked with `Closes #N`. A pull request that references an
open-for-contributors issue nobody holds (`Closes #N` in the body, `(#N)` in the title) claims
it the same way; if someone holds it already, or an older open pull request references it,
the bot says so on your pull request instead.
4. An issue with no visible progress for 14 days goes back to the pool: a daily sweep frees a claim
with no linked pull request 14 days after it was made, and one with a pull request after 14
days without pushes. `/unclaim` releases it early (you or a maintainer).
Issues labelled **in progress** are being worked on by the core team; please don't start on them.
The bot never lets them be claimed, and never frees an issue a maintainer holds.
## Labels and triage
The triage bot labels every new issue and pull request (`.github/workflows/triage.yml`). It only
adds labels; correct them freely and it won't put them back.
- **Pull requests** get `area:*` from the paths they change: `crates/mesh` → `area:mesh`,
`crates/lbm*` → `area:cfd`, `crates/solver`, `crates/fem`, `crates/explicit` → `area:solver`,
`crates/ui-egui` and `apps/` → `area:ui`, `crates/io`, `crates/decks` → `area:io`, `docs/` and
top-level `*.md` → `area:docs`, `.github/`, `xtask/`, `packaging/`, `scripts/`, `tools/` →
`area:infra`.
- **Issues** get `bug` or `enhancement` from the form they were filed with (or a crash or
"Feature request" title), and an `area:*` from words in the title or the command an in-app
report names. Until it has a priority, an issue carries `needs-triage`; adding a `priority:`
label removes it.
- **Maintainers** comment `/triage area:mesh high S` to sort an issue or pull request: any
existing label (case, and `-` or `_` for a space, don't matter: `in-progress`), `high` for
`priority: high`, `S`/`M`/`L` for a size (which replaces the old one), `-label` removes one.
Any `/triage` with a change clears `needs-triage`.
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
the steps (and the version, when it's missing). The reporter's reply, or an edit that adds the
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
reopened any time. Feature requests, reports filed from the app (**Help › Report a Bug…**),
and maintainers' issues are never asked.
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/fieldcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
## Reporting a problem or an idea
+48
View File
@@ -0,0 +1,48 @@
# 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.
+11 -1
View File
@@ -50,7 +50,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M21 Biomechanics and biomaterials | Soft tissue: Holzapfel–Gasser–Ogden fibre-reinforced hyperelasticity with dispersion, Fung, transversely isotropic models; biphasic and poro-hyperelastic cartilage (M19); active muscle (Hill-type); hydrogel swelling. Bone: density-dependent moduli from CT data, strain-energy-density remodelling (Huiskes, Beaupré). Growth and remodelling by kinematic growth (F = Fe·Fg). Implants and biomaterials (titanium, PEEK, UHMWPE) from the M17 library. Verified against published experiments with their tolerances | planned |
| M22 Design studies | Parameters of the SolidCraft design driven from FieldCraft: parametric sweeps, design of experiments, response surfaces and sensitivities; topology optimisation (SIMP with filters, manufacturing constraints); size and shape optimisation; submodelling (driven boundaries from a global model); uncertainty quantification | planned |
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 62 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 11 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | started |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 62 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 15 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE2, LE3, LE5, LE7, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | started |
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done; in-app updates — a daily check of GitHub Releases, Help › Check for Updates and Reinstall, downloads verified against the release key's signature of SHA256SUMS.txt and swapped in after closing, `app.update.check`, `fieldcraft-cli update`, Reload to update in the browser, docs/updates.md: done), command schema as the public API | started |
@@ -130,3 +130,13 @@ Temperature is available on fluid slices, obstacle surfaces and streamline colou
saved frames carry it. Signed face-average wall heat follows recorded histories and the
model's power/area units. Commands, the Results panel and headless pictures share the view;
initial frames without recorded wall heat remain uncoloured.
### Flow start-up transient and gallery clip (#164)
`results.animate` now plays a fluid step's frames in order, the start-up transient from the
uniform initial field to the settled flow, the way an explicit step plays its frames; the fluid Results
panel gains an Animate control. The `flow-past` gallery sample gains a clip and a solved still of
the air flowing past the L-bracket, regenerated by `tools/flow-clip/make.py` (the app rendered
frame by frame over the control channel, encoded with ffmpeg). The wake forms and the drag
settles; it does not shed (that needs a finer grid or a collision operator stable near τ⁺ = 0.5,
#557).
+29 -22
View File
@@ -3,47 +3,43 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>FieldCraft</title>
<title>OneCAD Simulation</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 property="og:type" content="website">
<meta property="og:site_name" content="FieldCraft">
<meta property="og:title" content="FieldCraft">
<meta property="og:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
<meta property="og:url" content="https://tangency-labs.github.io/fieldcraft/">
<meta property="og:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="FieldCraft: Open-source CAE built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="FieldCraft">
<meta name="twitter:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
<meta name="twitter:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
<!-- OneBSpace: upstream's social-card meta (og: / twitter:) and its og.png copy are
stripped — no social junk in this app's bundle. -->
<link data-trunk rel="rust" data-bin="fieldcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" type="image/svg+xml" href="../../assets/app-icon/icon-small.svg" />
<link data-trunk rel="copy-file" href="../../assets/app-icon/icon.iconset/icon_128x128@2x.png" data-target-path="icons" />
<link data-trunk rel="copy-file" href="../../docs/media/og.png" />
<link rel="apple-touch-icon" href="icons/icon_128x128@2x.png">
<!-- OneBSpace: the Odoo Documents host bridge glue (src/odoo). Copied verbatim
(not hashed): the framework's bundleVersion ?v= on the iframe URL is the
cache buster, like every other OneBSpace app bundle. -->
<link data-trunk rel="copy-dir" href="src/odoo" data-target-path="odoo" />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262c37; }
#fieldcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#fieldcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 16px; padding: 0 16px; text-align: center; color: #c3cad6; font: 14px system-ui, sans-serif; pointer-events: none; }
#fieldcraft_loading img { width: 96px; height: 96px; }
/* OneBSpace: the upstream logo image is gone — a plain wordmark instead. */
#fieldcraft_loading .wordmark { font: 600 clamp(28px, 6vmin, 52px) system-ui, sans-serif;
letter-spacing: 0.18em; text-indent: 0.18em; color: #c3cad6; user-select: none; }
#fieldcraft_loading p { margin: 0; max-width: 32em; }
#fieldcraft_loading.failed { pointer-events: auto; }
#fieldcraft_loading.failed img, #fieldcraft_loading.failed .spinner { display: none; }
#fieldcraft_loading.failed .spinner { display: none; }
.spinner { width: 24px; height: 24px; border-radius: 50%; border: 3px solid #4a5468; border-top-color: #c3cad6; animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
</style>
</head>
<body>
<canvas id="fieldcraft_canvas"></canvas>
<div id="fieldcraft_loading">
<img src="icons/icon_128x128@2x.png" alt="">
<div id="fieldcraft_loading" role="status">
<span class="wordmark">OneCAD Simulation</span>
<div class="spinner"></div>
<p>Loading FieldCraft&hellip;</p>
<noscript><p>FieldCraft needs JavaScript and WebAssembly.</p></noscript>
<span class="sr-only">Loading OneCAD Simulation&hellip;</span>
<!-- // OneBSpace (OneCAD Simulation): the no-JavaScript note names the fork -->
<noscript><p>OneCAD Simulation needs JavaScript and WebAssembly.</p></noscript>
</div>
<script>
// The page could not load or start the app (no WebAssembly, a failed download): say so
@@ -54,7 +50,8 @@
if (!el || el.classList.contains("failed")) return;
el.classList.add("failed");
el.textContent = "";
["FieldCraft could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
// // OneBSpace (OneCAD Simulation): the failure message names the fork
["OneCAD Simulation could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
var p = document.createElement("p");
p.textContent = t;
el.appendChild(p);
@@ -64,5 +61,15 @@
addEventListener("unhandledrejection", function (e) { fail(String(e.reason && e.reason.message || e.reason)); });
})();
</script>
<script>
// OneBSpace: boot the Odoo Documents bridge glue only inside a frame (the
// wasm_app_framework shell). Standalone runs never import it.
// odoo/index.js is src/odoo/index.ts compiled by the addon build script.
try {
if (window.parent !== window) {
import("./odoo/index.js?v=20.0.1.0.0").catch((e) => console.warn("OneCAD Simulation odoo bridge:", e));
}
} catch (e) { /* cross-origin parent (standalone) — skip */ }
</script>
</body>
</html>
+3
View File
@@ -26,6 +26,9 @@ mod storage;
mod usage;
#[cfg(target_arch = "wasm32")]
mod web;
// OneBSpace (OneCAD Simulation): Odoo Documents host integration
#[cfg(target_arch = "wasm32")]
mod services_odoo;
#[cfg(target_arch = "wasm32")]
fn main() {
+947
View File
@@ -0,0 +1,947 @@
// (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
@@ -0,0 +1,719 @@
// (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
@@ -0,0 +1,153 @@
// (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
@@ -0,0 +1,708 @@
// 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));
}
}
+13 -3
View File
@@ -86,7 +86,10 @@ pub fn start() {
web_options(),
Box::new(|cc| {
let inbox = Inbox::default();
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
let services = services(inbox.clone(), cc.egui_ctx.clone());
// OneBSpace (OneCAD Simulation): install odoo services on wasm
let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());
let mut app = ModelApp::new(Session::default()).with_services(services);
// Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md).
if let Some(host) = crate::usage::host() {
app = app.with_usage(host);
@@ -118,7 +121,7 @@ pub fn start() {
el.remove();
}
}
Err(e) => failed(&format!("FieldCraft could not start: {}", js_err(e))),
Err(e) => failed(&format!("OneCAD Simulation could not start: {}", js_err(e))),
}
});
}
@@ -143,7 +146,7 @@ fn failed(why: &str) {
// Best effort: the console has the message too.
let _ = el.class_list().add_1("failed");
for line in
[why, "FieldCraft needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
[why, "OneCAD Simulation needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
{
if let Ok(p) = document.create_element("p") {
p.set_text_content(Some(line));
@@ -227,6 +230,8 @@ impl Shell {
let mut paths = Vec::with_capacity(batch.len());
for (name, read) in batch {
let path = page_path(&name);
// OneBSpace (OneCAD Simulation): mirror computer imports into the Documents "Assets" folder
crate::services_odoo::mirror_import(&name, read.as_deref().ok());
match read.and_then(|bytes| fieldcraft_io::fs::insert(Path::new(&path), bytes).map_err(|e| e.to_string())) {
Ok(()) => paths.push(path),
Err(e) => self.app.set_status(format!("{name}: {e}"), true),
@@ -257,6 +262,9 @@ impl Shell {
}
Err(e) => log::warn!("autosave: {e}"),
}
} // OneBSpace (OneCAD Simulation): the shell air-gaps every other write through the Documents bridge
else if crate::services_odoo::handle_write(&path, &bytes) {
continue;
} else if let Err(e) = download(&path, &bytes) {
self.app.set_status(format!("{}: the download did not start ({e})", path.display()), true);
}
@@ -279,6 +287,8 @@ impl Shell {
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.open_arrived();
// OneBSpace (OneCAD Simulation): complete Documents picks (open/save-as/serialize)
crate::services_odoo::drain(&mut self.app);
self.app.logic(ctx);
self.autosave(ctx);
if self.app.now - self.sent_at >= crate::usage::INTERVAL {
+5 -2
View File
@@ -248,7 +248,9 @@ fn main() -> eframe::Result {
crash::phase("window");
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FieldCraft")
// OneBSpace (OneCAD Simulation): the window title names the fork (the APP_ID, which
// the packaged .desktop entries and the bundle id match, stays upstream).
.with_title("OneCAD Simulation")
.with_app_id(APP_ID)
.with_icon(app_icon())
.with_inner_size([1400.0, 900.0])
@@ -265,7 +267,8 @@ fn main() -> eframe::Result {
..Default::default()
};
eframe::run_native(
"FieldCraft",
// OneBSpace (OneCAD Simulation): the app name in the window title.
"OneCAD Simulation",
options,
Box::new(move |cc| {
crash::phase("app setup");
+9 -4
View File
@@ -21,7 +21,9 @@ pub const ISSUE_URL: &str = "https://github.com/tangency-labs/fieldcraft/issues/
/// The longest link a draft opens: GitHub refuses new-issue links much past 8 KB.
pub const MAX_URL: usize = 7000;
/// The session file a draft attaches.
pub const SESSION_FILE: &str = "fieldcraft-session.json";
// OneBSpace (OneCAD Simulation): the attachment name carries the fork's mark; the internal API
// name (`SESSION_FILE`) stays upstream.
pub const SESSION_FILE: &str = "onecadsim-session.json";
/// The model file a draft attaches.
pub const MODEL_FILE: &str = "model.fieldcraft";
/// Backtrace frames a crash's draft quotes.
@@ -158,8 +160,9 @@ pub fn draft(input: &Input, version: &str) -> Draft {
let crash = input.crash.as_ref();
let mut title = input.title.trim().to_string();
if title.is_empty() {
// OneBSpace (OneCAD Simulation): the fork's name in the drafted title.
title = match crash.and_then(|c| c.get("kind")).and_then(Value::as_str) {
Some("unclean_exit") => "FieldCraft closed unexpectedly".to_string(),
Some("unclean_exit") => "OneCAD Simulation closed unexpectedly".to_string(),
Some(_) => "Internal error".to_string(),
None => match input.observed.trim().lines().next().filter(|l| !l.is_empty()) {
Some(line) => format!("Bug: {}", line.chars().take(100).collect::<String>()),
@@ -224,14 +227,16 @@ pub fn draft(input: &Input, version: &str) -> Draft {
} else {
let text = serde_json::to_string_pretty(script).unwrap_or_default();
evidence.push_str(&format!(
"\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"
// OneBSpace (OneCAD Simulation): the fork's name in the evidence text.
"\nThe session is too long for this form: OneCAD Simulation saved it as `{SESSION_FILE}` and copied it to the clipboard. Attach the file here (drag it in) or paste it.\n"
));
attachments.insert(0, Attachment { name: SESSION_FILE.into(), bytes: text.clone().into_bytes(), what: "the session's commands".into() });
clipboard = Some(text);
}
}
if model.is_some() {
evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder FieldCraft saved it to).\n"));
// OneBSpace (OneCAD Simulation): the fork's name in the evidence text.
evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder OneCAD Simulation saved it to).\n"));
}
if url.is_empty() || model.is_some() {
let mut f = fields(evidence.clone());
+3 -1
View File
@@ -43,7 +43,9 @@ pub const CRASH_REPORTS_DELETE: CommandSpec = CommandSpec {
pub const BUG_REPORT: CommandSpec = CommandSpec {
id: "diagnostics.bug_report",
label: "Report a Bug",
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"#,
// OneBSpace (OneCAD Simulation): the draft stays local — no GitHub issue link is opened;
// the form's Copy report puts the report on the clipboard and saves attachments to a folder.
params: r#"{"title"?: "…", "observed"?: "what happened", "crash"?: "<crash report id>", "share_session"?: false, "include_model"?: false} — drafts a bug report that stays in the workspace: the version, the OS, what happened and, for a crash, where, and the files to attach. Only with share_session does it carry the model's commands, and only with include_model as well the model file. Nothing is sent: the form's Copy report copies the report to the clipboard and saves the attachments to a local folder"#,
form: Some(super::forms::BUG_REPORT),
kind: CommandKind::Session,
requires: &[],
+9 -6
View File
@@ -44,7 +44,7 @@ pub const MODES: CommandSpec = CommandSpec {
pub const ANIMATE: CommandSpec = CommandSpec {
id: "results.animate",
label: "Animate",
params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#,
params: r#"{"on": true} — animates the result over time: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed, an explicit or fluid step plays its frames in order"#,
form: Some(super::forms::ANIMATE),
kind: CommandKind::Session,
requires: &[Requirement::Result],
@@ -341,12 +341,12 @@ fn animate(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(s.results_report());
}
let step = step_for(s, &json!({}), Want::Any)?;
if s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_))) {
return Err(EngineError::Params(format!("step `{step}` is a fluid step: nothing deforms; show its frames with results.frame")));
}
let fluid = s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_)));
let v = view_of(s, &step)?;
v.animate = true;
v.deformed = true;
// A fluid step has nothing to deform; animating plays its frames (as results.frame does) in
// order. A structural step animates its deformation (or an explicit step its frames).
v.deformed = !fluid;
Ok(s.results_report())
}
@@ -1484,7 +1484,10 @@ mod tests {
assert_eq!((r["traced"]["seeds"].as_u64(), r["traced"]["points"].as_u64()), (Some(5), Some(20)), "{r}");
assert!(s.execute("results.streamlines", &json!({"show": false})).unwrap()["streamlines"].is_null());
assert_eq!(s.execute("results.surface", &json!({"show": false})).unwrap()["surface"], false);
assert!(s.execute("results.animate", &json!({})).is_err(), "nothing deforms");
// A fluid step animates its frames (the start-up transient), not a deformation.
let r = s.execute("results.animate", &json!({})).unwrap();
assert_eq!((r["view"]["animate"].as_bool(), r["view"]["deformed"].as_bool()), (Some(true), Some(false)), "{r}");
assert!(s.execute("results.animate", &json!({"on": false})).unwrap()["view"]["animate"] == json!(false));
assert_eq!((s.journal.len(), s.revision()), (journal, revision), "display commands are not edits");
// Structural steps have none of it.
let mut t = solved();
+6 -1
View File
@@ -110,7 +110,7 @@ pub const FLUID: CommandSpec = CommandSpec {
pub const PROBE: CommandSpec = CommandSpec {
id: "solve.probe",
label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity, pressure and (with heat) temperature in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories and {"step": "Flow", "heat": ["y_min"]} the heat into them over time (power, positive into the wall)"#,
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress, principal stresses (the values, largest first, without their directions) and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity, pressure and (with heat) temperature in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories and {"step": "Flow", "heat": ["y_min"]} the heat into them over time (power, positive into the wall)"#,
form: Some(super::forms::SOLVE_PROBE),
kind: CommandKind::Model,
requires: &[Requirement::Result],
@@ -717,6 +717,7 @@ fn probe_implicit(result: &ImplicitResult, step: &str, nodes: &[u32], p: &Value)
"reaction": r.reaction.get(i),
"reaction_moment": r.reaction_moment.get(i),
"stress": r.stress.get(i),
"principal": r.stress.get(i).map(fieldcraft_fem::principal),
"von_mises": r.von_mises.get(i),
"temperature": r.temperature.as_ref().and_then(|t| t.get(i)),
});
@@ -945,6 +946,10 @@ pub(crate) mod tests {
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [11, 1]})).unwrap();
assert!(probe["nodes"][0]["displacement"][2].as_f64().unwrap() < -3.8, "{probe}");
assert!(probe["nodes"][1]["reaction"][2].as_f64().unwrap() != 0.0, "{probe}");
// The principal stresses of the nodal stress, largest first.
let root = &probe["nodes"][1];
let stress: [f64; 6] = serde_json::from_value(root["stress"].clone()).unwrap();
assert_eq!(root["principal"], json!(fieldcraft_fem::principal(&stress)), "{root}");
// By location: the nearest node, with where it is.
let at = s.execute("solve.probe", &json!({"step": "Bend", "at": [[10.1, -0.2, 0.1], [0, 0, 0]]})).unwrap();
assert_eq!((at["nodes"][0]["node"].clone(), at["nodes"][1]["node"].clone()), (json!(11), json!(1)), "{at}");
+2 -1
View File
@@ -15,7 +15,8 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const CHECK: CommandSpec = CommandSpec {
id: "app.update.check",
label: "Check for Updates",
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"#,
// OneBSpace (OneCAD Simulation): the fork's name in the palette help text (desktop-only).
params: r#"{} — asks GitHub Releases for the latest published OneCAD Simulation release and compares it with this version: current, latest, newer, url (its page and release notes), published, signed (whether it can be verified and installed from the app) and asset (the file this install would download; null when it cannot update itself). Reports only: nothing is downloaded or installed. Desktop app and fieldcraft-cli only"#,
form: Some(&[]),
kind: CommandKind::Session,
requires: &[],
+7 -3
View File
@@ -493,7 +493,8 @@ fn hook(info: &std::panic::PanicHookInfo<'_>) -> bool {
// Outside any command: what the journal knows has to do.
let extra = from_journal(read_live_journal(state), None);
if let Some(id) = write_report(state, "panic", Some(&p), extra) {
eprintln!("FieldCraft: internal error; crash report {}", state.dir.join(&id).display());
// OneBSpace (OneCAD Simulation): the fork's name on the console.
eprintln!("OneCAD Simulation: internal error; crash report {}", state.dir.join(&id).display());
if let Ok(mut j) = state.journal.try_lock() {
j.record(&json!({ "reported": id }));
}
@@ -890,7 +891,9 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
let p = match &native {
Some(n) => native_panic(n, &was, &when, &during, &what),
None => Panic {
message: format!("FieldCraft {was} (started {when}) ended without a clean exit {during}"),
// OneBSpace (OneCAD Simulation): the fork's name in the crash message (shown in the
// crash question and in the report the user copies).
message: format!("OneCAD Simulation {was} (started {when}) ended without a clean exit {during}"),
location: match (&starting, &doing) {
(Some((p, _)), _) => format!("unclean exit during startup: {p}"),
(None, c) => format!("unclean exit in {}", c.as_deref().unwrap_or("(between commands)")),
@@ -958,7 +961,8 @@ fn native_panic(native: &Value, was: &str, when: &str, during: &str, what: &str)
backtrace.push(format!("on thread {t}"));
}
Panic {
message: format!("FieldCraft {was} (started {when}) crashed with {name} ({code}) in {at} {during}"),
// OneBSpace (OneCAD Simulation): the fork's name in the native-crash message.
message: format!("OneCAD Simulation {was} (started {when}) crashed with {name} ({code}) in {at} {during}"),
location: format!("native exception in {at}"),
thread: native.get("thread").and_then(Value::as_u64).map_or_else(|| "(unknown)".into(), |t| format!("native thread {t}")),
backtrace,
+134 -1
View File
@@ -355,6 +355,34 @@ pub fn plot_with_surfaces(
bodies: &[&str],
) -> Result<FlowPlot> {
let mut out = plot(view, result, traced)?;
if view.contour == Some(Field::Temperature)
&& let Kind::Fluid(r) = result.kind()
{
// An obstacle face held at a temperature is drawn at it: sampling the fluid beside it
// would mix in the cooler cells further out.
let held = held_temperatures(session, r, base, bodies);
if held.iter().any(Option::is_some) {
if let Some(c) = &mut out.contour {
// A held face beyond the fluid's range is the extreme: the legend, its Max/Min
// lines and the viewport's marker all name it. Its label continues past the
// cells, into `wall_positions` (see [`FlowPlot::wall_positions`]).
let cells = c.values.len();
for (t, v) in base.surface_tris.iter().zip(&held) {
let Some(v) = *v else { continue };
let at = || (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0);
if v > c.max.1 {
c.max = (cells + out.wall_positions.len(), v);
out.wall_positions.push(Some(at()));
} else if v < c.min.1 {
c.min = (cells + out.wall_positions.len(), v);
out.wall_positions.push(Some(at()));
}
}
c.bands = view.bands(c.min.1, c.max.1);
}
out.wall_values = base.surface_tris.iter().zip(held).map(|(t, v)| (t.p, v)).collect();
}
}
if view.contour == Some(Field::WallHeatFlux) {
let Kind::Fluid(r) = result.kind() else { return Ok(out) };
let faces = wall::faces(session, r, view.pick())?;
@@ -372,6 +400,52 @@ pub fn plot_with_surfaces(
Ok(out)
}
/// Per displayed surface triangle, the temperature its obstacle face is held at, when the step's
/// domain holds that face at one and the result is of the current model.
fn held_temperatures(session: &crate::Session, r: &FluidResult, base: &fieldcraft_render::DisplayMesh, bodies: &[&str]) -> Vec<Option<f64>> {
let faces = held_faces(session, r);
let held: std::collections::BTreeMap<_, _> = faces.iter().map(|h| ((h.body.as_str(), h.index), h.value)).collect();
base.surface_tris.iter().map(|t| bodies.get(t.body as usize).and_then(|name| held.get(&(*name, t.face)).copied())).collect()
}
/// An obstacle face the step's fluid domain holds at a temperature.
pub struct HeldFace {
pub body: String,
pub face: String,
pub index: u32,
pub value: f64,
}
/// The faces the step's fluid domain holds at a temperature, when the result is of the current
/// model (a stale result was solved with other conditions).
pub fn held_faces(session: &crate::Session, r: &FluidResult) -> Vec<HeldFace> {
if session.latest_result(&r.step).is_some_and(|(_, current)| !current) {
return Vec::new();
}
let Some(thermal) = session
.model
.step(&r.step)
.and_then(|st| st.procedure.fluid_domain())
.and_then(|d| session.model.fluid_domain(d))
.and_then(|d| d.thermal.as_ref())
else {
return Vec::new();
};
let design = session.design.as_deref();
let mut out = Vec::new();
for sb in &thermal.surfaces {
let (fieldcraft_model::ThermalCondition::Temperature { value }, fieldcraft_model::Entity::Face(face)) = (&sb.condition, &sb.face.entity)
else {
continue;
};
let index = design.and_then(|d| d.bodies.iter().find(|b| b.name == sb.face.body)).and_then(|b| b.face_index(face));
if let Some(index) = index {
out.push(HeldFace { body: sb.face.body.clone(), face: face.clone(), index, value: *value });
}
}
out
}
/// The colours a flow plot is drawn in.
#[derive(Clone, Copy, Debug)]
pub struct FlowColors<'a> {
@@ -401,7 +475,15 @@ pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
}
} else if plot.surface {
let model = std::mem::take(&mut scene.tris);
for t in model {
// Faces held at a temperature (`wall_values`, matched by position as for wall heat).
let held = (c.field == Field::Temperature && model.len() == plot.wall_values.len()).then_some(&plot.wall_values);
for (k, t) in model.into_iter().enumerate() {
if let Some((points, Some(v))) = held.and_then(|h| h.get(k))
&& t.p == *points
{
banded(t.p, [*v; 3], t.normal, &c.bands, &color, &mut scene.tris);
continue;
}
let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p));
let (sum, known) = v.iter().filter(|x| !x.is_nan()).fold((0.0, 0u32), |(s, n), x| (s + x, n + 1));
if known == 0 {
@@ -542,6 +624,57 @@ pub(crate) mod tests {
s
}
/// A face held at a temperature is drawn at that temperature, and the legend reaches it,
/// rather than at the mean of the fluid cells around it (which mixes in the cooler ones
/// further out: an 80 °C bracket looked 50 °C).
#[test]
fn a_face_held_at_a_temperature_is_drawn_at_it() {
let mut s = plate_in_a_tank();
let face = s.design.as_ref().unwrap().bodies[0].faces()[0].name.clone();
s.execute(
"model.material.edit",
&json!({"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}, "thermal": {"conductivity": 400, "specific_heat": 4e9}}),
)
.unwrap();
s.execute(
"model.fluid_domain.edit",
&json!({"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"],
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}},
"thermal": {"initial_temperature": 20}}),
)
.unwrap();
s.execute(
"model.fluid_domain.thermal_boundary",
&json!({"domain": "Tank", "at": {"body": "Body1", "face": face}, "condition": {"type": "temperature", "value": 80}}),
)
.unwrap();
s.execute("solve.fluid", &json!({})).unwrap();
s.execute("results.surface", &json!({"field": "temperature", "show": true})).unwrap();
let (result, current) = s.latest_result("Flow").unwrap();
assert!(current);
let view = s.results_view.clone().unwrap();
let base = crate::view::display(&s).0;
let names: Vec<&str> = s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.as_str()).collect();
let fp = plot_with_surfaces(&view, result, &s.traced, &s, &base, &names).unwrap();
let c = fp.contour.as_ref().unwrap();
// The fluid never quite reaches the wall's 80 °C; the legend does, and its maximum is the
// held face (a label past the cells, at a point on that face).
assert!(c.values.iter().all(|t| t.is_nan() || *t < 80.0));
assert_eq!((c.max.1, c.bands.max), (80.0, 80.0), "{:?}", c.bands);
let at = c.max.0.checked_sub(c.values.len()).and_then(|k| fp.wall_positions.get(k).copied().flatten());
assert!(at.is_some(), "{:?}", c.max);
let held: Vec<f64> = fp.wall_values.iter().filter_map(|(_, v)| *v).collect();
assert!(!held.is_empty() && held.iter().all(|t| *t == 80.0), "{held:?}");
// A script reads the same maximum as the app shows: the held face.
let reply = s.results_report();
assert_eq!(reply["contour"]["range"]["max"], json!({"body": "Body1", "face": face, "value": 80.0}), "{reply}");
// The headless picture draws the held face in the top band's colour.
let p = crate::view::Palette::LIGHT;
let top = bands_color(&p.contour, p.contour_over, &c.bands, c.bands.band(80.0).unwrap());
let (scene, ..) = crate::view::scene(&s, &p);
assert!(scene.tris.iter().any(|t| t.color == top));
}
#[test]
fn a_fluid_result_is_first_shown_across_the_flow() {
let r = channel();
+12
View File
@@ -722,6 +722,18 @@ impl Session {
out[k.as_str()] = x;
}
}
// A face held beyond the fluid's range is the extreme, as the legend shows it.
if v.contour == Some(Field::Temperature) && out["contour"]["range"].is_object() {
for h in crate::flow::held_faces(self, f) {
let end = json!({"body": h.body, "face": h.face, "value": h.value});
let range = &mut out["contour"]["range"];
if range["max"]["value"].as_f64().is_some_and(|m| h.value > m) {
range["max"] = end;
} else if range["min"]["value"].as_f64().is_some_and(|m| h.value < m) {
range["min"] = end;
}
}
}
if v.contour == Some(Field::WallHeatFlux)
&& let Ok(faces) = crate::flow::wall::faces(self, f, v.pick())
{
+8 -3
View File
@@ -916,8 +916,8 @@ fn assignable(loops: &[Poly], cuts: &[Vec<Q>], s: &Settings) -> bool {
fn o_grid(loops: &[Poly], s: &Settings, budget: &mut usize) -> Vec<Vec<Q>> {
let (Some(outer), Some(hole)) = (loops.first(), loops.get(1)) else { return Vec::new() };
let c = centroid(&hole.pts);
let corner = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) <= 0.75 * std::f64::consts::PI;
let concave = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) >= 1.25 * std::f64::consts::PI;
let corner = |p: &Poly, i: usize| p.is_end(i) && region::is_corner(p.angle(i));
let concave = |p: &Poly, i: usize| p.is_end(i) && region::is_reflex(p.angle(i));
// A cut from loop point (l, i) along d that must end on loop `target`.
let ray = |l: usize, i: usize, d: Q, target: usize| -> Option<Vec<Q>> {
let o = loops.get(l)?.at(i);
@@ -1134,8 +1134,9 @@ mod tests {
let n = ((a1 - a0).abs() / 5.0).ceil().max(1.0) as usize;
(0..=n)
.map(|k| {
// The mesher's portable trig, so every platform gets the same points (#556).
let t = (a0 + (a1 - a0) * k as f64 / n as f64).to_radians();
[c[0] + r * t.cos(), c[1] + r * t.sin()]
[c[0] + r * libm::cos(t), c[1] + r * libm::sin(t)]
})
.collect()
}
@@ -1308,6 +1309,10 @@ mod tests {
]);
let m = mesh_profile(&profile, &[], 2.5);
check(&m, &profile, 0.5);
// The intended O-grid on every platform (#556): Windows' last-bit-different arcs used to
// give 10 regions and 2063 quads with a 0.375 corner.
assert_eq!(m.section.schemes.len(), 9, "{:?}", m.section.schemes);
assert_eq!(m.section.quads.iter().map(Vec::len).sum::<usize>(), 848);
}
#[test]
+45 -3
View File
@@ -47,6 +47,24 @@ const MAX_ANNULUS_NODES: usize = 4096;
/// Smoothing sweeps over the interior of a triangle primitive.
const TRIANGLE_SWEEPS: usize = 20;
/// How close (radians) an interior angle must come to a classification threshold to count as on
/// it. Exact geometry lands on the thresholds (an O-grid's 45° cuts meet straight sides at
/// exactly 135°), and the computed angle is then rounded either way by the last bits of its
/// points: those differ between platforms' `sin`/`cos` and between tessellations, and the
/// scheme must not (#556). An angle within this of a threshold is taken as exactly on it, and
/// on a threshold is smooth, neither a corner nor reflex: that keeps the slot's intended O-grid.
const ANGLE_TOLERANCE: f64 = 1e-9;
/// A corner: an interior angle clearly below 135° (exactly 135° is not one).
pub(crate) fn is_corner(angle: f64) -> bool {
angle < 0.75 * PI - ANGLE_TOLERANCE
}
/// A reflex turn no mapped or triangle region can take: clearly above 225°.
pub(crate) fn is_reflex(angle: f64) -> bool {
angle > 1.25 * PI + ANGLE_TOLERANCE
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Scheme {
/// Corner positions (curve uses) in the single loop.
@@ -89,10 +107,10 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
match loops {
[lp] => {
let Some(a) = angles(lp, ccw(lp)) else { return Scheme::Free };
if a.iter().any(|&x| x >= 1.25 * PI) {
if a.iter().any(|&x| is_reflex(x)) {
return Scheme::Free;
}
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| x <= 0.75 * PI)).collect();
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| is_corner(x))).collect();
if corners.is_empty() {
return Scheme::Disk;
}
@@ -103,7 +121,7 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
}
}
[outer, inner] => {
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| x > 0.75 * PI && x < 1.25 * PI));
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| !is_corner(x) && !is_reflex(x)));
if smooth(outer) && smooth(inner) { Scheme::Annulus } else { Scheme::Free }
}
_ => Scheme::Free,
@@ -568,6 +586,30 @@ mod tests {
use super::*;
use crate::size::UniformSize;
/// An angle of exactly 135°, the threshold, is not a corner however the last bits of its
/// points round (Windows' `sin` against glibc's, or two tessellations): a trapezoid with one
/// stays a triangle rather than flipping to mapped when that vertex moves by an ulp (#556).
#[test]
fn an_angle_on_the_threshold_is_not_a_corner_whichever_way_it_rounds() {
// (0, 0) 90°, (3, 0) 45°, (2, 1) 135°, (0, 1) 90°: three corners and a 135° bend.
let trapezoid = |p: Q| {
let pts = [[0.0, 0.0], [3.0, 0.0], p, [0.0, 1.0]];
let curves: Vec<Vec<Q>> = (0..4).map(|k| vec![pts[k], pts[(k + 1) % 4]]).collect();
classify(&[curves])
};
let steps: [fn(f64) -> f64; 3] = [|v| v, f64::next_up, f64::next_down];
for nx in steps {
for ny in steps {
let mut p = [nx(2.0), ny(1.0)];
// A few ulps further each way as well.
for _ in 0..3 {
assert_eq!(trapezoid(p), Scheme::Triangle([0, 1, 3]), "{p:?}");
p = [nx(p[0]), ny(p[1])];
}
}
}
}
fn circle(r: f64, n: usize, ccw: bool) -> Vec<Q> {
(0..n)
.map(|k| {
+41 -78
View File
@@ -61,49 +61,17 @@ fn button_style(t: &Tokens) -> IconButtonStyle {
}
}
/// FieldCraft's mark: the application icon's fringe cube (`assets/app-icon/icon-small.svg`, after
/// `xtask/src/app_icon.rs`) drawn in code without its tile or streamline: each visible face split
/// into small cells coloured by the fringe band at the cell's centre, shaded per face.
fn mark(p: &egui::Painter, c: egui::Pos2) {
// The cube is 2 units tall on screen (half side 0.5, isometric); draw it 22 points tall.
const S: f32 = 11.0;
const N: usize = 8;
const FRINGE: [(u8, u8, u8); 7] =
[(0x2c, 0x4f, 0xbf), (0x2f, 0x86, 0xe0), (0x22, 0xbc, 0xd8), (0x3f, 0xc5, 0x84), (0xf0, 0xd1, 0x3a), (0xf2, 0x8a, 0x2b), (0xde, 0x3b, 0x2f)];
const RADII: [f32; 6] = [1.28, 1.07, 0.87, 0.67, 0.47, 0.27];
let at = |q: [f32; 3]| c + vec2((q[0] - q[1]) * 0.866 * S, ((q[0] + q[1]) * 0.5 - q[2]) * S);
// Each face: origin corner, two edge vectors, and how much it is darkened (0 to 1).
let faces: [([f32; 3], [f32; 3], [f32; 3], f32); 3] = [
([-0.5, -0.5, 0.5], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.0),
([-0.5, 0.5, -0.5], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 0.12),
([0.5, -0.5, -0.5], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], 0.3),
];
let point = |o: [f32; 3], u: [f32; 3], v: [f32; 3], a: f32, b: f32| [0, 1, 2].map(|k| o[k] + u[k] * a + v[k] * b);
for (o, u, v, shade) in faces {
for i in 0..N {
for j in 0..N {
let (a0, a1, b0, b1) = (i as f32 / N as f32, (i + 1) as f32 / N as f32, j as f32 / N as f32, (j + 1) as f32 / N as f32);
let m = point(o, u, v, (a0 + a1) / 2.0, (b0 + b1) / 2.0);
let r = m.iter().map(|x| (0.5 - x) * (0.5 - x)).sum::<f32>().sqrt();
let band = RADII.iter().filter(|&&rad| r < rad).count();
let (cr, cg, cb) = FRINGE.get(band).copied().unwrap_or(FRINGE[0]);
let k = 1.0 - shade;
let col = Color32::from_rgb((cr as f32 * k) as u8, (cg as f32 * k) as u8, (cb as f32 * k) as u8);
let quad = vec![at(point(o, u, v, a0, b0)), at(point(o, u, v, a1, b0)), at(point(o, u, v, a1, b1)), at(point(o, u, v, a0, b1))];
// A hairline in the cell's own colour closes the seams between neighbouring cells.
p.add(egui::Shape::convex_polygon(quad, col, Stroke::new(0.5, col)));
}
}
}
}
// OneBSpace (OneCAD Simulation): upstream's drawn fringe-cube mark (`mark`, after
// `assets/app-icon/icon-small.svg`) is replaced by a painted text wordmark, so the bar names
// the fork in every width variant (a narrow bar loses the menus, not the app's own name).
pub const APP_NAME: &str = "OneCAD Simulation";
/// Where the title bar's parts go.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BarLayout {
/// The mark's centre, and where the app's name starts (none when compact).
/// The wordmark's left anchor ("OneCAD Simulation", painted text).
pub mark: egui::Pos2,
pub name: Option<egui::Pos2>,
/// File, Save, Undo, Redo: after the mark, at the left.
/// File, Save, Undo, Redo: after the wordmark, at the left.
pub quick: [Rect; 4],
/// Solve, then Search, Theme and Help: at the right end, before the version.
pub solve: Rect,
@@ -112,19 +80,23 @@ pub struct BarLayout {
pub tab_room: (f32, f32),
/// The version's right edge.
pub version_right: f32,
/// Too narrow for labels: no name, and Solve without its word.
/// Too narrow for menus: Solve without its word (the wordmark still shows).
pub compact: bool,
}
/// The title bar's layout in `bar`. `traffic_lights`: the macOS window buttons sit over its left
/// end (an integrated title bar out of full screen, where there are none). `captions`: the width
/// of the custom caption buttons at its right end; `version_w`: the version text's width.
pub fn bar_layout(bar: Rect, traffic_lights: bool, captions: f32, version_w: f32) -> BarLayout {
/// of the custom caption buttons at its right end; `version_w`: the version text's width;
/// `name_w`: the wordmark's width (OneBSpace (OneCAD Simulation): the quick actions start after
/// the wordmark, in every width variant).
pub fn bar_layout(bar: Rect, traffic_lights: bool, captions: f32, version_w: f32, name_w: f32) -> BarLayout {
let left = bar.left() + if traffic_lights { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
let compact = bar.right() - left < 760.0;
let cy = bar.center().y;
let button = |x: f32| Rect::from_center_size(pos2(x + BUTTON / 2.0, cy), Vec2::splat(BUTTON));
let x0 = left + if compact { 44.0 } else { 132.0 };
// OneBSpace (OneCAD Simulation): the quick actions start after the wordmark in compact
// layouts too (upstream's compact bar dropped the name and started at the bare mark).
let x0 = left + 14.0 + name_w + 22.0;
let quick = [0.0, 1.0, 2.0, 3.0].map(|i| button(x0 + i * STRIDE));
let version_right = bar.right() - 12.0 - captions;
let tools_end = version_right - version_w - 14.0;
@@ -132,8 +104,7 @@ pub fn bar_layout(bar: Rect, traffic_lights: bool, captions: f32, version_w: f32
let solve_w = if compact { 30.0 } else { 74.0 };
let solve = Rect::from_min_size(pos2(tools[0].left() - 10.0 - solve_w, cy - 12.0), vec2(solve_w, 24.0));
BarLayout {
mark: pos2(left + 20.0, cy),
name: (!compact).then(|| pos2(left + 36.0, cy)),
mark: pos2(left + 14.0, cy),
quick,
solve,
tools,
@@ -161,11 +132,11 @@ pub fn app_bar(app: &mut ModelApp, ui: &mut egui::Ui) {
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
let version = format!("v{}", crate::VERSION);
let version_w = ui.painter().layout_no_wrap(version.clone(), FontId::proportional(11.0), t.text_dim).size().x;
let lay = bar_layout(r, app.integrated_titlebar && !fullscreen(ui.ctx()), captions, version_w);
mark(ui.painter(), lay.mark);
if let Some(at) = lay.name {
ui.painter().text(at, Align2::LEFT_CENTER, "FieldCraft", FontId::proportional(14.0), t.app_bar_text);
}
// OneBSpace (OneCAD Simulation): the wordmark is measured so the quick actions clear it.
let name_w = ui.painter().layout_no_wrap(APP_NAME.to_string(), FontId::proportional(14.0), t.app_bar_text).size().x;
let lay = bar_layout(r, app.integrated_titlebar && !fullscreen(ui.ctx()), captions, version_w, name_w);
// OneBSpace (OneCAD Simulation): painted wordmark instead of the drawn mark + name.
ui.painter().text(lay.mark, Align2::LEFT_CENTER, APP_NAME, FontId::proportional(14.0), t.app_bar_text);
let (undo, redo) = app.session.undo_depth();
let busy = app.busy().is_some();
let file_open = app.ui.menu.as_deref() == Some("file");
@@ -336,30 +307,16 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) {
ui.label("Left drag orbits (or pans or zooms: the navigation bar), right or middle drag pans, the wheel zooms, F or a double click fits.");
ui.label(format!("S or {} searches every command; the command line at the bottom runs `id {{json}}`.", shortcut("K")));
ui.separator();
let report = ui
.button("Report a Bug…")
.on_hover_text("Draft a GitHub issue with the version, the OS and the last error; nothing is sent until you submit it")
.clicked();
let (mut check, mut reinstall) = (false, false);
if app.updates_available() {
check =
ui.button("Check for Updates").on_hover_text("Ask GitHub Releases whether a newer FieldCraft is published (app.update.check)").clicked();
reinstall = ui
.button("Reinstall FieldCraft…")
.on_hover_text("Download this version again, verify it and swap it in, to repair a damaged install")
.clicked();
}
// OneBSpace (OneCAD Simulation): upstream's "Report a Bug…", "Check for Updates" and
// "Reinstall FieldCraft…" items are gone — nothing here reaches GitHub or an update host.
// A report is drafted locally ("Report It…" after a crash, Help › Crash Reports) and copied
// out by hand; the version label names the fork.
let 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();
ui.label(egui::RichText::new(format!("FieldCraft {}", crate::VERSION)).color(t.text_dim));
if report {
app.report_bug(None);
} else if crashes {
// OneBSpace (OneCAD Simulation): the version label carries the fork's name.
ui.label(egui::RichText::new(format!("{} {}", APP_NAME, crate::VERSION)).color(t.text_dim));
if crashes {
app.open_crash_reports();
} else if check {
app.check_for_updates();
} else if reinstall {
app.reinstall();
} else if settings {
app.open_settings();
}
@@ -377,13 +334,16 @@ mod tests {
/// at the very left in full screen (where there are none).
#[test]
fn the_title_bar_keeps_solve_right_and_drops_the_traffic_light_inset_in_full_screen() {
// OneBSpace (OneCAD Simulation): the layout takes the wordmark's width; a plausible
// measured value stands in for the font here.
let name_w = 120.0;
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(1400.0, HEIGHT));
let windowed = bar_layout(bar, true, 0.0, 30.0);
let full = bar_layout(bar, false, 0.0, 30.0);
let windowed = bar_layout(bar, true, 0.0, 30.0, name_w);
let full = bar_layout(bar, false, 0.0, 30.0, name_w);
assert_eq!(windowed.mark.x - full.mark.x, crate::titlebar::TRAFFIC_LIGHTS);
assert!(full.mark.x < 30.0 && full.quick[0].left() < 150.0, "{full:?}");
assert!(full.mark.x < 30.0 && full.quick[0].left() < 200.0, "{full:?}");
for l in [windowed, full] {
// Left to right: mark, quick actions, (tab), Solve, search, theme, help, version.
// Left to right: wordmark, quick actions, (tab), Solve, search, theme, help, version.
assert!(l.quick.windows(2).all(|w| w[0].right() <= w[1].left()));
assert!(l.quick[3].right() < l.tab_room.0 && l.tab_room.1 < l.solve.left());
assert!(l.solve.right() <= l.tools[0].left() && l.tools.windows(2).all(|w| w[0].right() <= w[1].left()));
@@ -392,10 +352,13 @@ mod tests {
}
// The right group does not move with full screen; custom caption buttons push it left.
assert_eq!(windowed.solve, full.solve);
let captions = bar_layout(bar, false, crate::titlebar::WIDTH, 30.0);
let captions = bar_layout(bar, false, crate::titlebar::WIDTH, 30.0, name_w);
assert!((full.tools[2].right() - captions.tools[2].right() - crate::titlebar::WIDTH).abs() < 0.01);
// Narrow: compact, still in order.
let narrow = bar_layout(Rect::from_min_size(egui::Pos2::ZERO, vec2(700.0, HEIGHT)), true, 0.0, 30.0);
assert!(narrow.compact && narrow.name.is_none() && narrow.quick[3].right() < narrow.solve.left());
// Narrow: compact, the wordmark still first and the quick actions after it, in order.
let narrow = bar_layout(Rect::from_min_size(egui::Pos2::ZERO, vec2(700.0, HEIGHT)), true, 0.0, 30.0, name_w);
assert!(narrow.compact && narrow.quick[3].right() < narrow.solve.left(), "{narrow:?}");
// OneBSpace (OneCAD Simulation): every width shows the wordmark, and the quick actions
// start after it (they no longer sit on the bare mark in the compact bar).
assert!(narrow.quick[0].left() >= narrow.mark.x + name_w, "{narrow:?}");
}
}
+1 -1
View File
@@ -1115,7 +1115,7 @@ impl ModelApp {
/// frame; `None` when no results are shown.
fn plot_key(&self) -> Option<PlotKey> {
let (view, result, current) = results::shown(&self.session)?;
let frame = results::frame(view, self.now);
let frame = results::frame(view, result, self.now);
let pick = results::pick(view, result, frame);
Some(PlotKey { revision: self.revision, result: Arc::as_ptr(result) as usize, current, view: view.clone(), pick, frame })
}
+3 -1
View File
@@ -77,7 +77,9 @@ pub const DEFAULT_PRESET: &str = "fieldcraft";
pub const NAV_PRESETS: [NavPreset; 7] = [
NavPreset {
id: DEFAULT_PRESET,
label: "FieldCraft",
// OneBSpace (OneCAD Simulation): the preset's label names the fork (its `id`, which
// 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",
reverse_zoom: false,
bindings: &[bind(Left, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Pan), bind(Middle, NavKeys::NONE, Pan)],
+27 -19
View File
@@ -12,10 +12,12 @@ use fieldcraft_engine::usage::{self, Event, Identity, Origin};
use crate::ModelApp;
use crate::files::Prompt;
/// What is sent, event by event.
/// What is sent, event by event. (OneBSpace (OneCAD Simulation): upstream's PRIVACY_URL linked
/// out to github.com/tangency-labs/fieldcraft; the constant stays for reference but the UI no
/// longer renders it as a link or opens it — nothing here browses anywhere.)
pub const PRIVACY_URL: &str = "https://github.com/tangency-labs/fieldcraft/blob/main/docs/privacy.md";
/// The question, as the first run asks it.
pub const QUESTION: &str = "Help improve FieldCraft by sharing anonymous usage: which tools you use, nothing from your models.";
/// The question, as the first run asks it. (OneBSpace (OneCAD Simulation): fork name.)
pub const QUESTION: &str = "Help improve OneCAD Simulation by sharing anonymous usage: which tools you use, nothing from your models.";
const DETAIL: &str = "Counted: the commands you run (their names, how long they took, whether they failed), the steps from a design to results, \
the samples you open, and the version, OS and a random id for this install. Never sent: file names or paths, geometry, model \
contents, parameter values or messages.";
@@ -143,14 +145,17 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let t = crate::theme::Tokens::get(app.ui.dark);
match app.prompt {
Some(Prompt::Consent) => {
let (mut share, mut decline, mut link, mut updates) = (false, false, false, None);
crate::frame::window(ctx, "Help Improve FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_consent")).show(ctx, |ui| {
// OneBSpace (OneCAD Simulation): the privacy note is plain text — the upstream
// GitHub link (PRIVACY_URL) is not rendered as a link and never opened.
let (mut share, mut decline, mut updates) = (false, false, None);
// OneBSpace (OneCAD Simulation): the fork's name in the dialog title.
crate::frame::window(ctx, "Help Improve OneCAD Simulation", crate::frame::Width::Normal).id(egui::Id::new("fc_consent")).show(ctx, |ui| {
ui.label(QUESTION);
ui.add_space(4.0);
ui.label(RichText::new(DETAIL).color(t.text_dim).size(11.0));
ui.add_space(2.0);
ui.label(RichText::new("You can change this any time in Help › Settings.").color(t.text_dim).size(11.0));
link = ui.link("What is sent (docs/privacy.md)").on_hover_text(PRIVACY_URL).clicked();
ui.label(RichText::new("What is sent: docs/privacy.md in the repository.").color(t.text_dim).size(11.0));
if app.updates_available() {
ui.add_space(4.0);
updates = update_check(ui, &t, app.prefs.update_check);
@@ -161,9 +166,6 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
decline = ui.button("Don't Share").clicked();
});
});
if link {
app.open_link(ctx, PRIVACY_URL);
}
if let Some(on) = updates {
app.set_update_prefs(on, None);
}
@@ -176,15 +178,20 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
Some(Prompt::Settings) => {
let available = app.usage_available();
let (mut sharing, mut automation) = (app.prefs.usage_sharing == Some(true), app.prefs.usage_automation);
let (mut close, mut link, mut updates, mut unskip, mut check) = (false, false, None, false, false);
// OneBSpace (OneCAD Simulation): the privacy note is plain text — no link, no
// browser. The dialog never opens PRIVACY_URL.
let (mut close, mut updates, mut unskip, mut check) = (false, None, false, false);
crate::frame::window(ctx, "Settings", crate::frame::Width::Normal).id(egui::Id::new("fc_settings")).show(ctx, |ui| {
if app.updates_available() {
crate::frame::menu_heading(ui, &t, "UPDATES");
updates = update_check(ui, &t, app.prefs.update_check);
ui.horizontal(|ui| {
check = ui.button("Check Now").on_hover_text("Help › Check for Updates").clicked();
// OneBSpace (OneCAD Simulation): the tooltip no longer points at Help ›
// Check for Updates (that menu item was removed with the fork's rebrand).
check = ui.button("Check Now").on_hover_text("Ask GitHub Releases for the latest version now").clicked();
if !app.prefs.update_skip.is_empty() {
ui.label(RichText::new(format!("Skipping FieldCraft {}", app.prefs.update_skip)).color(t.text_dim).size(11.0));
// OneBSpace (OneCAD Simulation): the fork's name in the skip note.
ui.label(RichText::new(format!("Skipping OneCAD Simulation {}", app.prefs.update_skip)).color(t.text_dim).size(11.0));
unskip = ui.small_button("Offer It Again").clicked();
}
});
@@ -207,11 +214,13 @@ 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("Crash reports and the log stay on this computer; Help › Report a Bug sends only what you submit.")
// OneBSpace (OneCAD Simulation): the report flow is local-only now (a draft
// to copy), so the note no longer promises a GitHub submission.
RichText::new("Crash reports and the log stay on this computer; a report is drafted here and copied out — nothing is sent anywhere.")
.color(t.text_dim)
.size(11.0),
);
link = ui.link("What is sent (docs/privacy.md)").on_hover_text(PRIVACY_URL).clicked();
ui.label(RichText::new("What is sent: docs/privacy.md in the repository.").color(t.text_dim).size(11.0));
ui.add_space(6.0);
close = ui.button("Close").clicked();
});
@@ -224,9 +233,6 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
if check {
app.check_for_updates();
}
if link {
app.open_link(ctx, PRIVACY_URL);
}
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
}
@@ -241,11 +247,13 @@ fn update_check(ui: &mut egui::Ui, t: &crate::theme::Tokens, on: bool) -> Option
let changed = ui
.checkbox(&mut checked, "Check for updates automatically")
.on_hover_text(
"At start-up, at most once a day, FieldCraft asks GitHub Releases for the latest version. Nothing installs until you choose Install.",
// OneBSpace (OneCAD Simulation): the fork's name in the tooltip (desktop-only check).
"At start-up, at most once a day, OneCAD Simulation asks GitHub Releases for the latest version. Nothing installs until you choose Install.",
)
.changed();
ui.label(
RichText::new("The check sends only the request (your IP address and the FieldCraft version reach GitHub); off means no check.")
// OneBSpace (OneCAD Simulation): the fork's name in the note.
RichText::new("The check sends only the request (your IP address and the OneCAD Simulation version reach GitHub); off means no check.")
.color(t.text_dim)
.size(11.0),
);
+54 -53
View File
@@ -1,13 +1,13 @@
//! Help › Report a Bug, and the question the window asks after a crash ("FieldCraft closed
//! unexpectedly — Report it").
//! Help › Crash Reports, "Report It…" after a crash ("OneCAD Simulation closed unexpectedly —
//! Report it"), and the Report a Bug form they open.
//!
//! The form shows exactly what the GitHub issue form will be filled with
//! ([`fieldcraft_engine::bug_report`]): the version, the OS, what happened (the last error by
//! default) and, after a crash, where it happened. **Share my session** adds the commands that
//! built the model, **Include the model file** the model as well. Nothing leaves the computer
//! until the user clicks **Open GitHub Issue**, which opens the filled form in their browser (they
//! submit it there, or not); files too big for the link are saved to a folder to attach from, and
//! a long session is also put on the clipboard.
// OneBSpace (OneCAD Simulation): upstream opened the filled GitHub issue form in the browser;
// the fork's report flow is local-only — the form shows exactly what the draft holds
// ([`fieldcraft_engine::bug_report`]): the version, the OS, what happened (the last error by
// default) and, after a crash, where it happened. **Share my session** adds the commands that
// built the model, **Include the model file** the model as well. **Copy report** puts it all on
// the clipboard (nothing opens, nothing is sent); attachments are saved to a local folder to
// attach from wherever the user files their reports.
use egui::RichText;
use fieldcraft_engine::bug_report::{self, Draft, Input};
@@ -169,37 +169,29 @@ impl ModelApp {
}
}
/// Open the draft in the browser: attachments saved to a folder of their own, the session on
/// the clipboard when it is too long for the link.
fn send_report(&mut self, ctx: &egui::Context, draft: &Draft) {
let mut saved = None;
if !draft.attachments.is_empty() {
// OneBSpace (OneCAD Simulation): the report never leaves the workspace — no GitHub issue
// form is opened (upstream opened the draft's ISSUE_URL). The draft's text goes to the
// clipboard and any attachments stay in the local bug-reports folder to attach by hand.
fn copy_report(&mut self, ctx: &egui::Context, draft: &Draft) {
let mut text = draft.title.clone();
for (k, v) in &draft.fields {
if !v.is_empty() {
text.push_str(&format!("\n\n{k}:\n{v}"));
}
}
if let Some(session) = &draft.clipboard {
text.push_str(&format!("\n\nsession:\n{session}"));
}
ctx.copy_text(text);
let note = if draft.attachments.is_empty() {
"Report copied to the clipboard — paste it into your own issue tracker.".to_string()
} else {
match bug_report::save_attachments(draft) {
Ok(dir) => saved = Some(dir),
Err(e) => {
self.set_status(format!("Report a Bug: could not save the files to attach: {e}"), true);
return;
}
Ok(dir) => format!("Report copied to the clipboard: attach the files in {}.", dir.display()),
Err(e) => format!("Report copied to the clipboard (the files to attach could not be saved: {e})."),
}
}
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(text, false);
self.set_status(note, false);
self.prompt = None;
self.report = None;
}
@@ -226,10 +218,11 @@ fn size(bytes: usize) -> String {
}
fn kind_label(kind: &str) -> &'static str {
// OneBSpace (OneCAD Simulation): the fork's name in the crash headings.
match kind {
"unclean_exit" => "FieldCraft closed unexpectedly",
"unclean_exit" => "OneCAD Simulation closed unexpectedly",
"command_panic" => "A command hit an internal error",
_ => "FieldCraft hit an internal error",
_ => "OneCAD Simulation hit an internal error",
}
}
@@ -245,8 +238,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let (mut report, mut close) = (false, false);
crate::frame::window(ctx, kind_label(&first.kind), crate::frame::Width::Normal).id(egui::Id::new("fc_crashed")).show(ctx, |ui| {
ui.label(match first.kind.as_str() {
"unclean_exit" => "FieldCraft closed unexpectedly the last time it ran.",
_ => "FieldCraft hit an internal error the last time it ran.",
// OneBSpace (OneCAD Simulation): the fork's name in the crash question.
"unclean_exit" => "OneCAD Simulation closed unexpectedly the last time it ran.",
_ => "OneCAD Simulation hit an internal error the last time it ran.",
});
ui.label(RichText::new(&first.message).color(t.text_dim));
if reports.len() > 1 {
@@ -254,8 +248,10 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
}
ui.add_space(4.0);
ui.label(
// OneBSpace (OneCAD Simulation): reporting drafts locally — no GitHub issue
// is opened; the user copies the report into their own tracker.
RichText::new(
"A crash report was saved on this computer. Reporting it opens a GitHub issue you review first; nothing is sent until you submit it.",
"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.",
)
.color(t.text_dim),
);
@@ -285,7 +281,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
ui.label("No crash reports are saved on this computer.");
} else {
ui.label(
RichText::new("Saved on this computer, newest first. Reporting one opens a GitHub issue you review first; nothing is sent until you submit it.")
// OneBSpace (OneCAD Simulation): reporting drafts locally — no GitHub
// issue is opened; the report is copied out by hand.
RichText::new("Saved on this computer, newest first. Reporting one drafts a report that stays in the workspace: copy it into your own issue tracker.")
.color(t.text_dim),
);
ui.add_space(4.0);
@@ -344,14 +342,14 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
app.prompt = None;
return;
};
let (mut send, mut close) = (false, false);
let (mut copy, mut close) = (false, false);
let commands = form.input.commands.len();
crate::frame::window(ctx, "Report a Bug", crate::frame::Width::Wide).id(egui::Id::new("fc_report_bug")).show(ctx, |ui| {
ui.label(
RichText::new(
"This opens a GitHub issue in your browser, filled in with what is shown below. Nothing is sent until you submit it there.",
)
.color(t.text_dim),
// OneBSpace (OneCAD Simulation): the report stays in the workspace — it is
// copied to the clipboard, never opened as a GitHub issue in the browser.
RichText::new("Your report stays in the workspace: copy it and paste it into your own issue tracker. Nothing is sent anywhere.")
.color(t.text_dim),
);
if let Some(p) = &form.problem {
ui.label(RichText::new(p).color(t.error));
@@ -386,11 +384,12 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let draft = form.draft().clone();
ui.add_space(6.0);
ui.horizontal(|ui| {
send = ui.button("Open GitHub Issue").clicked();
// OneBSpace (OneCAD Simulation): "Copy report" instead of "Open GitHub Issue".
copy = ui.button("Copy report").clicked();
close = ui.button("Cancel").clicked();
});
ui.add_space(4.0);
egui::CollapsingHeader::new("What is sent").id_salt("fc_report_sent").default_open(true).show(ui, |ui| {
egui::CollapsingHeader::new("What is in the report").id_salt("fc_report_sent").default_open(true).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
ui.label(RichText::new(format!("Title: {}", draft.title)).monospace().size(11.0));
for (k, v) in &draft.fields {
@@ -403,15 +402,17 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
ui.label(RichText::new(format!("Attach {} ({}): {}", a.name, size(a.bytes.len()), a.what)).size(11.0).color(t.accent));
}
if draft.clipboard.is_some() {
ui.label(RichText::new("The session goes on the clipboard too, to paste.").size(11.0).color(t.text_dim));
// OneBSpace (OneCAD Simulation): the session is copied with the report.
ui.label(RichText::new("The session is copied with the report.").size(11.0).color(t.text_dim));
}
});
});
});
let draft = form.draft().clone();
app.report = Some(form);
if send {
app.send_report(ctx, &draft);
// OneBSpace (OneCAD Simulation): the copied report never touches the network.
if copy {
app.copy_report(ctx, &draft);
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
app.report = None;
+53 -10
View File
@@ -118,7 +118,14 @@ impl Plot {
return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten();
}
match self.result.kind() {
Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))),
Kind::Fluid(f) => {
let label = usize::try_from(label).ok()?;
// Past the cells: a face held at a temperature that is the field's extreme.
match label.checked_sub(f.grid.len()) {
Some(k) => self.flow.as_ref()?.wall_positions.get(k).copied().flatten(),
None => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, label))),
}
}
_ => self.node_position(label),
}
}
@@ -136,9 +143,12 @@ pub fn shown(session: &Session) -> Option<(&ResultsView, &Arc<StepResult>, bool)
Some((view, result, current))
}
/// The animation frame shown at time `now` (seconds), when animating.
pub fn frame(view: &ResultsView, now: f64) -> Option<u64> {
(view.animate && view.deformed && now.is_finite()).then(|| ((now.max(0.0) / PERIOD * FRAMES as f64).floor() as u64) % FRAMES)
/// The animation frame shown at time `now` (seconds), when animating. A deformed step swings its
/// shape; a fluid step plays its frames though it does not deform; a step with nothing to animate
/// (Animate on but Deformed off, and not fluid) stays still.
pub fn frame(view: &ResultsView, result: &StepResult, now: f64) -> Option<u64> {
let plays = view.deformed || matches!(result.kind(), Kind::Fluid(_));
(view.animate && plays && now.is_finite()).then(|| ((now.max(0.0) / PERIOD * FRAMES as f64).floor() as u64) % FRAMES)
}
/// The deformation multiplier of an animation frame: a mode swings through ±1, a static step
@@ -161,6 +171,7 @@ pub fn pick(view: &ResultsView, result: &StepResult, frame: Option<u64>) -> Pick
let mut p = view.pick();
let frames = match result.kind() {
Kind::Explicit(e) => e.frames.len(),
Kind::Fluid(f) => f.frames.len(),
Kind::Static(r) => r.nonlinear.as_ref().map_or(0, |h| h.frames.len()),
_ => 0,
};
@@ -474,12 +485,34 @@ pub(crate) mod tests {
let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t);
assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn");
assert!(!scene.lines.is_empty(), "streamlines and the part's edges are drawn");
// Pressure on the slice; the first frame (at rest at the inlet speed everywhere).
// Pressure on the slice; the first frame. This plate_in_a_tank fixture sets no initial
// velocity, so it starts at rest (unlike the flow-past sample's uniform inlet field).
s.execute("results.slice", &json!({"field": "pressure", "frame": 0})).unwrap();
let p = plot_at(&s, &base, None).unwrap();
assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0)));
}
/// Animating a fluid step plays its frames: the clock maps across the frames once per cycle,
/// and results.animate turns it on without deforming the part (the flow start-up transient).
#[test]
fn a_fluid_step_animates_its_frames() {
let mut s = plate_in_a_tank(false);
let (base, _) = fieldcraft_engine::view::display(&s);
s.execute("results.animate", &json!({})).unwrap();
let view = s.results_view.as_ref().unwrap();
let result = s.latest_result(&view.step).unwrap().0;
assert!(view.animate && !view.deformed, "fluid animates its frames, not a deformation");
// The animation clock runs even though the fluid step does not deform.
assert_eq!(frame(view, result, 0.0), Some(0));
assert_eq!(frame(view, result, PERIOD * 0.5), Some(FRAMES / 2));
// The clock maps across the three frames: the plate_in_a_tank field, which starts at rest
// (the fixture sets no initial velocity, unlike the flow-past sample), then the settled flow.
let n = plot_at(&s, &base, None).unwrap().frame().unwrap().2;
assert_eq!(n, 3);
assert_eq!(plot_at(&s, &base, Some(0)).unwrap().pick.frame, Some(0));
assert_eq!(plot_at(&s, &base, Some(FRAMES - 1)).unwrap().pick.frame, Some(n - 1));
}
#[test]
fn heated_flow_fields_have_units_colours_and_face_markers() {
let mut s = plate_in_a_tank(true);
@@ -492,7 +525,13 @@ pub(crate) mod tests {
assert!(p.contour.as_ref().unwrap().max.1 > 20.0);
let last = p.frame().unwrap().2 - 1;
s.execute("results.frame", &json!({"frame": 0})).unwrap();
assert_eq!(plot_at(&s, &base, None).unwrap().contour.as_ref().unwrap().max.1, 20.0, "the frame selector changes the temperature shown");
let at_rest = plot_at(&s, &base, None).unwrap();
let c = at_rest.contour.as_ref().unwrap();
let cells = &at_rest.flow.as_ref().unwrap().contour.as_ref().unwrap().values;
assert!(cells.iter().filter(|t| t.is_finite()).all(|t| *t == 20.0), "the frame selector changes the temperature shown");
// The faces are held at 60 °C from the start: the legend's maximum is a face, at a point on it.
assert_eq!(c.max.1, 60.0);
assert!(at_rest.extreme_position(c.max.0).is_some());
s.execute("results.frame", &json!({"frame": last})).unwrap();
s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
let p = plot_at(&s, &base, None).unwrap();
@@ -518,10 +557,14 @@ pub(crate) mod tests {
s.execute("results.animate", &json!({})).unwrap();
let base = DisplayMesh::from_model(&s.model);
let view = s.results_view.clone().unwrap();
assert_eq!(frame(&view, 0.0), Some(0));
assert_eq!(frame(&view, PERIOD * 0.25), Some(FRAMES / 4));
assert_eq!(frame(&view, PERIOD * 1.25), Some(FRAMES / 4));
let (_, result, _) = shown(&s).unwrap();
let result = s.latest_result(&view.step).unwrap().0;
assert_eq!(frame(&view, result, 0.0), Some(0));
assert_eq!(frame(&view, result, PERIOD * 0.25), Some(FRAMES / 4));
assert_eq!(frame(&view, result, PERIOD * 1.25), Some(FRAMES / 4));
// Animate on but Deformed off, and not a fluid step: nothing to animate, so it stays still.
let mut still = view.clone();
still.deformed = false;
assert_eq!(frame(&still, result, PERIOD * 0.25), None);
assert!((swing(result, Some(FRAMES / 4)) - 1.0).abs() < 1e-12);
assert!((swing(result, Some(3 * FRAMES / 4)) + 1.0).abs() < 1e-12);
// At frame 0 a mode is undeformed; a quarter cycle later fully deformed.
+4
View File
@@ -364,6 +364,10 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
if ui.add(egui::Slider::new(&mut frame, 0..=n.saturating_sub(1))).changed() {
run.push(("results.frame", json!({"frame": frame})));
}
let mut animate = plot.view.animate;
if ui.checkbox(&mut animate, "Animate").on_hover_text("Play the frames from the start-up transient to the settled flow").changed() {
run.push(("results.animate", json!({"on": animate})));
}
});
ui.label(RichText::new(format!("t = {}", with_unit(number(at), Quantity::Time.unit(plot.units).as_deref()))).color(t.text_dim));
} else if let Some((k, factor, n)) = plot.frame().filter(|_| matches!(plot.result.kind(), Kind::Static(_))) {
+22 -16
View File
@@ -123,9 +123,9 @@ impl ModelApp {
self.set_status(format!("Cannot reinstall: {why}"), true);
return;
}
u.busy = Some(format!("Downloading FieldCraft {}…", crate::VERSION));
u.busy = Some(format!("Downloading OneCAD Simulation {}…", crate::VERSION)); // OneBSpace (OneCAD Simulation): fork name in the status
(u.host.start)(Job::Reinstall);
self.set_status(format!("Downloading FieldCraft {} to reinstall it…", crate::VERSION), false);
self.set_status(format!("Downloading OneCAD Simulation {} to reinstall it…", crate::VERSION), false); // OneBSpace (OneCAD Simulation)
}
/// The notice's Install: download and verify, then ask to restart. A copy that cannot
@@ -133,15 +133,16 @@ impl ModelApp {
pub fn install_update(&mut self, ctx: &egui::Context) {
let Some(u) = &mut self.updates else { return };
let Some(release) = u.available.clone() else { return };
let blocked = u.host.unsupported.clone().or_else(|| (!release.signed()).then(|| format!("FieldCraft {} is not signed", release.version)));
// OneBSpace (OneCAD Simulation): the fork's name in the not-signed status.
let blocked = u.host.unsupported.clone().or_else(|| (!release.signed()).then(|| format!("OneCAD Simulation {} is not signed", release.version)));
if let Some(why) = blocked {
self.set_status(format!("{why}: opening the download page"), true);
self.open_link(ctx, &release.url);
return;
}
u.busy = Some(format!("Downloading FieldCraft {}…", release.version));
u.busy = Some(format!("Downloading OneCAD Simulation {}…", release.version)); // OneBSpace (OneCAD Simulation)
(u.host.start)(Job::Stage(release.clone()));
self.set_status(format!("Downloading FieldCraft {}…", release.version), false);
self.set_status(format!("Downloading OneCAD Simulation {}…", release.version), false); // OneBSpace (OneCAD Simulation)
}
/// Skip the offered version: the automatic check no longer offers it.
@@ -178,7 +179,7 @@ impl ModelApp {
&& let Some((s, Finish::Swap(_))) = &u.staged
{
let v = s.version.to_string();
self.set_status(format!("FieldCraft {v} installs when you quit"), false);
self.set_status(format!("OneCAD Simulation {v} installs when you quit"), false); // OneBSpace (OneCAD Simulation)
}
return;
}
@@ -234,14 +235,16 @@ impl ModelApp {
let v = check.latest.version.to_string();
u.available = Some(check.latest);
if manual {
self.set_status(format!("FieldCraft {v} is available"), false);
// OneBSpace (OneCAD Simulation): the fork's name in the status.
self.set_status(format!("OneCAD Simulation {v} is available"), false);
}
}
Ok(check) => {
// Nothing newer (or skipped): a notice from an earlier check goes.
u.available = None;
if manual {
self.set_status(format!("FieldCraft {} is up to date", check.current), false);
// OneBSpace (OneCAD Simulation): the fork's name in the status.
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),
@@ -310,7 +313,8 @@ pub fn notice(app: &mut ModelApp, ui: &mut egui::Ui) {
if ui.add(egui::Link::new(RichText::new("Reload to update").size(11.5))).on_hover_text(tip).clicked() {
reload();
}
ui.label(RichText::new("A new version of FieldCraft is available —").size(11.5).color(t.text));
// OneBSpace (OneCAD Simulation): the fork's name in the status-bar notice.
ui.label(RichText::new("A new version of OneCAD Simulation is available —").size(11.5).color(t.text));
ui.separator();
return;
}
@@ -328,7 +332,8 @@ pub fn notice(app: &mut ModelApp, ui: &mut egui::Ui) {
let install = ui.add(egui::Link::new(small("Install"))).on_hover_text("Download, verify and install it; you choose when to restart").clicked();
ui.label(small("·").color(t.text_dim));
let notes = ui.add(egui::Link::new(small("What's new"))).on_hover_text(release.url.as_str()).clicked();
ui.label(small(&format!("FieldCraft {} is available —", release.version)).color(t.text));
// OneBSpace (OneCAD Simulation): the fork's name in the status-bar notice.
ui.label(small(&format!("OneCAD Simulation {} is available —", release.version)).color(t.text));
ui.separator();
if notes {
app.open_link(ui.ctx(), &release.url);
@@ -353,12 +358,13 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
};
let reinstall = staged.version.to_string() == crate::VERSION;
let (mut restart, mut later, mut open) = (false, false, false);
crate::frame::window(ctx, "Update FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_update")).show(ctx, |ui| match &finish {
// OneBSpace (OneCAD Simulation): the fork's name in the window title.
crate::frame::window(ctx, "Update OneCAD Simulation", crate::frame::Width::Normal).id(egui::Id::new("fc_update")).show(ctx, |ui| match &finish {
Finish::Swap(_) => {
ui.label(if reinstall {
format!("FieldCraft {} was downloaded again and verified. Restart to reinstall it?", staged.version)
format!("OneCAD Simulation {} was downloaded again and verified. Restart to reinstall it?", staged.version)
} else {
format!("FieldCraft {} is downloaded and verified. Restart now to finish installing it?", staged.version)
format!("OneCAD Simulation {} is downloaded and verified. Restart now to finish installing it?", staged.version)
});
ui.label(
RichText::new(format!("It replaces {}. Unsaved changes are saved or asked about first.", staged.install.describe()))
@@ -368,11 +374,11 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
ui.add_space(6.0);
ui.horizontal(|ui| {
restart = ui.button("Restart Now").clicked();
later = ui.button("Later").on_hover_text("It installs when you quit FieldCraft").clicked();
later = ui.button("Later").on_hover_text("It installs when you quit OneCAD Simulation").clicked(); // OneBSpace (OneCAD Simulation)
});
}
Finish::Package { file, command } => {
ui.label(format!("FieldCraft {} is downloaded and verified:", staged.version));
ui.label(format!("OneCAD Simulation {} is downloaded and verified:", staged.version)); // OneBSpace (OneCAD Simulation)
ui.label(RichText::new(file.display().to_string()).monospace().size(11.0));
ui.label("Install it with your system's package manager:");
let mut text = command.clone();
@@ -516,7 +522,7 @@ mod tests {
tx.send(Event::Checked { manual: true, result: Ok(Check { current: current.clone(), latest: release(crate::VERSION) }) }).unwrap();
a.poll_updates(&ctx);
assert_eq!(a.status.as_ref().map(|s| s.text.clone()), Some(format!("FieldCraft {} is up to date", crate::VERSION)));
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.update_offered(), None, "the earlier offer goes");
tx.send(Event::Checked { manual: false, result: Err("offline".into()) }).unwrap();
a.status = None;
+9 -1
View File
@@ -52,7 +52,7 @@ contact (#161) the ring will be pressed by contact against a groove.
Air (ρ = 1.2 kg/m³, μ = 1.8 × 10⁻⁵ Pa s) at 25 mm/s past the same L-bracket standing on the
floor, the upright's back to the wind. A 240 × 120 × 100 mm box of 2.5 mm cells (184 320),
uniform inlet, outlet at zero pressure, a no-slip floor and slip walls at the sides and top;
D3Q19 lattice Boltzmann (TRT) for 16 s, about 6.7 flow-throughs, ten frames. Reynolds number
D3Q19 lattice Boltzmann (TRT) for 16 s, about 1.7 passes through the 240 mm box, ten frames. Reynolds number
83 on the upright's 50 mm height: laminar, a steady wake. About 10 s.
**Look at:** the step's report (`solve.fluid`, or Results after Solve): the force on every
@@ -72,6 +72,14 @@ velocity magnitude on a slice through the middle of the box (`results.slice`; pr
`field: "pressure"`), the bracket coloured by the flow beside it (`results.surface`) and
streamlines seeded upstream of it, coloured by speed (`results.streamlines`).
![The solved flow: velocity on the mid-slice, a slow wake behind the upright and faster flow over it, with streamlines bending around the bracket](media/examples/flow-past-flow.png)
**Watch it start up:** a fluid step animates its frames (Results ▸ Animate, or `results.animate`),
playing the start-up transient from the uniform 25 mm/s initial field to the settled wake — the
wake forming behind the upright and the drag settling over the 16 s run. A clip is at
[flow-past-transient.mp4](media/examples/flow-past-transient.mp4), regenerated by
[tools/flow-clip](../tools/flow-clip/README.md).
## Heated bracket (`heated-bracket`)
![The steel bracket's temperature, 120 °C at the heated end of the upright falling to 95 °C at the foot](media/examples/heated-bracket.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
+11 -1
View File
@@ -2,7 +2,7 @@
Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the policy is [verification.md](verification.md). Every case is our own model, built with FieldCraft commands from the problem's published description, and compared with the reference value its `expected.json` cites, within the stated tolerance and for the stated reason. Accepted results are frozen in each case's `fixture.json`: a later change that moves one beyond its band (10⁻⁹ of its size) fails CI even while the reference still passes. No reference value was produced with a commercial product.
**277 of 283 quantities pass** (6 known failure(s)) in 73 active cases: 62 theory, 11 published benchmark, 0 experiment; 8 pending cases wait for capabilities still being built.
**282 of 289 quantities pass** (7 known failure(s)) in 77 active cases: 62 theory, 15 published benchmark, 0 experiment; 8 pending cases wait for capabilities still being built.
## Theory: closed-form solutions, patch tests and mesh invariants
@@ -293,6 +293,12 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
| [nafems-le1](../tests/oracle/nafems-le1/) | σ_yy at D (2, 0) | 92.7 | 92.869 | 0.182% | 1% | pass | matches |
| [nafems-le1-plane-stress](../tests/oracle/nafems-le1-plane-stress/) | σ_yy at D (2, 0) | 92.7 | 92.648 | -0.0559% | 1% | pass | matches |
| [nafems-le10](../tests/oracle/nafems-le10/) | σ_yy at D (2, 0, 0.3) on the loaded face | -5.38 | -5.3339 | 0.857% | 2% | pass | matches |
| [nafems-le2](../tests/oracle/nafems-le2/) | tangential stress at E, outer surface, load case 1 (edge moment) | 60 | 59.936 | -0.107% | 1% | pass | matches |
| [nafems-le2](../tests/oracle/nafems-le2/) | tangential stress at E, outer surface, load case 2 (pressure and edge traction) | 60 | 59.904 | -0.161% | 1% | pass | matches |
| [nafems-le3](../tests/oracle/nafems-le3/) | radial displacement at A (10, 0, 0), m | 0.185 | 0.18418 | -0.446% | 2% | pass | matches |
| [nafems-le5](../tests/oracle/nafems-le5/) | σ_xx at A (2.5, −1, −1), mid-surface of the lower flange at its free edge | -108 | -109.45 | -1.35% | 2% | pass | matches |
| [nafems-le7](../tests/oracle/nafems-le7/) | axial stress σ_yy at D (1.0125, 1.4034), outer surface | 25.86 | 25.283 | -2.23% | 1% | known failure: the target is the axisymmetric thin-shell solution and FieldCraft has no axisymmetric shell element; our axisymmetric solid model converges to 25.27 (−2.3%), the value ESRD reports for its converged axisymmetric solid (quantity syy_D_solid) | — |
| [nafems-le7](../tests/oracle/nafems-le7/) | axial stress σ_yy at D (1.0125, 1.4034), outer surface, against the converged solid model | 25.27 | 25.283 | 0.0496% | 1% | pass | matches |
| [pinched-cylinder](../tests/oracle/pinched-cylinder/) | radial displacement under the load, mid-surface | -1.8248e-5 | -1.8038e-5 | 1.15% | 2% | pass | matches |
| [scordelis-lo-roof-shell](../tests/oracle/scordelis-lo-roof-shell/) | vertical displacement at the midpoint of the free edge, mid-surface | -0.3024 | -0.29901 | 1.12% | 2% | pass | matches |
| [scordelis-lo-roof-solid](../tests/oracle/scordelis-lo-roof-solid/) | vertical displacement at the midpoint of the free edge, mid-surface | -0.3024 | -0.3014 | 0.33% | 2% | pass | matches |
@@ -335,6 +341,10 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
- **[nafems-le1](../tests/oracle/nafems-le1/)** (benchmark): NAFEMS LE1 elliptic membrane (hex20 plane stress layer). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE1 (target σ_yy = 92.7 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE1: Plane stress elements—elliptic membrane'. Tolerances — syy_D: the target is published to three figures; plane stress modelled by a thin solid layer and the stress extrapolated to the edge add a few tenths of a percent
- **[nafems-le1-plane-stress](../tests/oracle/nafems-le1-plane-stress/)** (benchmark): NAFEMS LE1 elliptic membrane with 2D plane stress elements (quad8). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE1 (target σ_yy = 92.7 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE1: Plane stress elements—elliptic membrane'. Tolerances — syy_D: the target is published to three figures; the stress extrapolated to the edge on a converged mesh adds a few tenths of a percent
- **[nafems-le10](../tests/oracle/nafems-le10/)** (benchmark): NAFEMS LE10 thick plate under pressure (hex20). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE10 (target σ_yy = −5.38 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE10: Thick plate under pressure'. Tolerances — syy_D: the target is published to three figures; at D the stress is extrapolated to a corner where the hole edge meets the loaded face, and published converged solutions scatter by 1–2% (Abaqus Benchmarks Guide fine meshes: −5.51 to −5.89)
- **[nafems-le2](../tests/oracle/nafems-le2/)** (benchmark): NAFEMS LE2 cylindrical shell bending patch test (MITC4 shells). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE2 (target: tangential stress 60 MPa on the outer surface at E, both load cases); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.2 'LE2: Cylindrical shell bending patch test', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 8. Tolerances — stt_E_moment: the target is the exact thin-shell value (6M/t²) and the stress is uniform over the patch; the 8 × 8 flat facets tilt the element axes by ±1.875° from the hoop direction, which moves 0.1% of it into σ_zz, and 16 × 16 is within 0.03% (see README); stt_E_pressure: the target is the exact membrane value (pR/t) and the stress is uniform over the patch; as in load case 1 the facets' tilt and the pressure on flat facets keep 8 × 8 within 0.2%, 16 × 16 within 0.05% (see README)
- **[nafems-le3](../tests/oracle/nafems-le3/)** (benchmark): NAFEMS LE3 hemispherical shell with point loads (MITC4 shells). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE3 (target: radial displacement 0.185 m at A); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.3 'LE3: Hemispherical shell with point loads', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 12. Tolerances — ur_A: the target is published to three figures; the displacement under a point load on a shear-deformable shell keeps growing slowly as the mesh is refined (32 × 32 is 0.44% low, 64 × 64 0.16%; see README)
- **[nafems-le5](../tests/oracle/nafems-le5/)** (benchmark): NAFEMS LE5 Z-section cantilever (hex20). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE5 (target: axial stress −108 MPa at A, mid-surface); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.5 'LE5: Z-section cantilever', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 15. Tolerances — sxx_A: the target is published to three figures and comes from a thin-walled idealisation; a 3D solid model of the section converges about 1.5% beyond it (ours: −109.46 here, −109.60 on the next mesh; ESRD's p-version solid: −109.5, see README)
- **[nafems-le7](../tests/oracle/nafems-le7/)** (benchmark): NAFEMS LE7 axisymmetric cylinder/sphere under pressure (axisymmetric quad8). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE7 (target: axial stress 25.86 MPa on the outer surface at D, R = 1.0125, Z = 1.4034), as reproduced in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 23, which also gives its converged axisymmetric solid result, 25.27 MPa (p. 24); the Abaqus 2016 Benchmarks Guide, section 4.2.7 'LE7: Axisymmetric cylinder/sphere under pressure', gives the target rounded to 25.9 MPa. Tolerances — syy_D: the target is published to four figures for the thin-shell model of the vessel, and a model of the same theory converges well within 1%; syy_D_solid: the same model idealisation (axisymmetric solid) as the reference, which ESRD reports converged to under 1% on two meshes; ours moves by 0.05% on the next refinement (see README)
- **[nl-contact-patch](../tests/oracle/nl-contact-patch/)** (theory): Contact patch test: two blocks with non-matching meshes in uniaxial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; Crisfield, Re-visiting the contact patch test, IJNME 48:435–449, 2000; passed by mortar methods: Puso & Laursen, CMAME 193:601–629, 2004. Uniaxial stress between frictionless symmetry planes is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its mortar integrals exact on these rectangular faces: a uniform stress is the discrete solution up to the convergence tolerance (10⁻¹⁰); small_lower_centre: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: frictionless symmetry planes and a frictionless interface leave the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface; finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_centre: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: frictionless symmetry planes and a frictionless interface leave the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface; small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness, a few 10⁻⁵ of the squeeze)
- **[nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/)** (theory): Contact patch test, axisymmetric: two rings with non-matching edges in axial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; passed by 2D mortar methods: Yang, Laursen & Meng, IJNME 62:1183–1225, 2005. Uniaxial stress between frictionless supports is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its edge mortar integrals exact (degree-3 Gauss on the overlaps): the uniform stress is the discrete solution up to the convergence tolerance; small_lower_far: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface (both are totals over the circumference); finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_far: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface (both are totals over the circumference); small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness)
- **[nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/)** (theory): Contact patch test in plane strain: two blocks with non-matching edges in uniaxial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; passed by 2D mortar methods: Yang, Laursen & Meng, IJNME 62:1183–1225, 2005. Uniaxial stress between frictionless supports is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its edge mortar integrals exact (degree-3 Gauss on the overlaps): the uniform stress is the discrete solution up to the convergence tolerance; small_lower_far: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface (both are totals over the thickness); finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_far: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface (both are totals over the thickness); small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness)
+414
View File
@@ -0,0 +1,414 @@
// The /claim bot's logic, used by .github/workflows/claim.yml through actions/github-script.
// Everything takes the github-script `github` client and `context`, so the tests can pass fakes.
'use strict';
// CONTRIBUTING.md, "Before you start: claim an issue": a claimed issue is assigned and labelled
// `claimed`; `in progress` marks the core team's own work and can't be claimed; contributors pick
// issues labelled `help wanted` or `good first issue`; no visible progress for 14 days frees it.
const LABEL = 'claimed';
const LABEL_COLOR = '0052cc';
const LABEL_DESCRIPTION = 'Assigned to a contributor; see CONTRIBUTING.md';
const CORE = 'in progress';
const OPEN = ['help wanted', 'good first issue'];
const CLAIM_DAYS = 14;
const DAY = 24 * 3600 * 1000;
function parseCommand(body) {
const text = String(body || '').trimStart();
if (/^\/claim(\s|$)/.test(text)) return 'claim';
if (/^\/unclaim(\s|$)/.test(text)) return 'unclaim';
return null;
}
function fmt(date) {
return new Date(date).toISOString().slice(0, 16).replace('T', ' ') + ' UTC';
}
function repoArgs(context) {
return { owner: context.repo.owner, repo: context.repo.repo };
}
async function isMaintainer(github, context, login, cache) {
if (login === context.repo.owner) return true;
if (cache && cache.has(login)) return cache.get(login);
let result = false;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
...repoArgs(context),
username: login,
});
result =
['admin', 'write', 'maintain'].includes(data.permission) ||
['admin', 'maintain', 'write'].includes(data.role_name);
} catch (e) {
result = false;
}
if (cache) cache.set(login, result);
return result;
}
async function ensureLabel(github, context) {
try {
await github.rest.issues.getLabel({ ...repoArgs(context), name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({
...repoArgs(context),
name: LABEL,
color: LABEL_COLOR,
description: LABEL_DESCRIPTION,
});
} catch (e2) {
if (e2.status !== 422) throw e2; // created meanwhile
}
}
}
// When the label was last added, from the timeline; the issue's update time if it can't be found.
async function labeledAt(github, context, issue, timeline) {
const events = timeline || (await getTimeline(github, context, issue.number));
let at = null;
for (const ev of events) {
if (ev.event === 'labeled' && ev.label && ev.label.name === LABEL) at = ev.created_at;
}
return new Date(at || issue.updated_at).getTime();
}
function getTimeline(github, context, number) {
return github.paginate(github.rest.issues.listEventsForTimeline, {
...repoArgs(context),
issue_number: number,
per_page: 100,
});
}
// Open pull requests that mention the issue, from the timeline's cross-references.
function linkedPulls(timeline) {
const seen = new Map();
for (const ev of timeline) {
const src = ev.event === 'cross-referenced' && ev.source && ev.source.issue;
if (src && src.pull_request && src.state === 'open') seen.set(src.number, src);
}
return [...seen.values()];
}
async function lastPush(github, context, pr) {
const { data } = await github.rest.pulls.get({ ...repoArgs(context), pull_number: pr.number });
const { data: commit } = await github.rest.repos.getCommit({
...repoArgs(context),
ref: data.head.sha,
});
return new Date(commit.commit.committer.date).getTime();
}
// Where a claimed issue stands: when it lapses, and whether it is free to release now.
async function claimStatus(github, context, issue, now) {
const timeline = await getTimeline(github, context, issue.number);
const since = await labeledAt(github, context, issue, timeline);
const pulls = linkedPulls(timeline);
if (pulls.length === 0) {
const expires = since + CLAIM_DAYS * DAY;
return { pulls, since, expires, release: now >= expires, reason: 'no PR' };
}
let newest = since;
for (const pr of pulls) newest = Math.max(newest, await lastPush(github, context, pr));
const expires = newest + CLAIM_DAYS * DAY;
return { pulls, since, expires, release: now >= expires, reason: 'idle PR' };
}
async function react(github, context, content) {
await github.rest.reactions.createForIssueComment({
...repoArgs(context),
comment_id: context.payload.comment.id,
content,
});
}
async function reply(github, context, body) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: context.payload.issue.number,
body,
});
}
function labelNames(issue) {
return (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
}
function hasLabel(issue, name = LABEL) {
return labelNames(issue).includes(name);
}
// Open to contributors: labelled `help wanted` or `good first issue`, and not the core team's.
function claimable(issue) {
const names = labelNames(issue);
return !names.includes(CORE) && OPEN.some((n) => names.includes(n));
}
async function release(github, context, issue, assignees) {
if (assignees.length) {
await github.rest.issues.removeAssignees({
...repoArgs(context),
issue_number: issue.number,
assignees,
});
}
try {
await github.rest.issues.removeLabel({
...repoArgs(context),
issue_number: issue.number,
name: LABEL,
});
} catch (e) {
if (e.status !== 404) throw e;
}
}
async function claim(github, context, now) {
const issue = context.payload.issue;
const user = context.payload.comment.user.login;
const n = issue.number;
const cache = new Map();
const assignees = (issue.assignees || []).map((a) => a.login);
if (assignees.includes(user)) {
await react(github, context, '+1'); // already theirs
return;
}
if (hasLabel(issue, CORE)) {
await react(github, context, 'eyes');
await reply(github, context, `The core team is working on this one (\`${CORE}\`), so it can't be claimed.`);
return;
}
if (!claimable(issue) && !(await isMaintainer(github, context, user, cache))) {
await react(github, context, 'eyes');
await reply(
github,
context,
`Only issues labelled \`help wanted\` or \`good first issue\` can be claimed, @${user}. ` +
'Say here what you would like to do and a maintainer will open it up or assign it to you.',
);
return;
}
// Someone else is assigned: theirs unless the claim has lapsed (never for a maintainer).
let held = assignees.length > 0 || hasLabel(issue);
let status = null;
if (held) {
let maintained = false;
for (const a of assignees) maintained = maintained || (await isMaintainer(github, context, a, cache));
if (!maintained) {
status = await claimStatus(github, context, issue, now);
if (status.release) held = false;
}
if (held) {
await react(github, context, 'eyes');
const who = assignees.length ? assignees.map((a) => '@' + a).join(', ') + ' has' : 'Someone has';
const when = maintained || !status ? '' : ` The claim lapses on ${fmt(status.expires)} unless there's progress.`;
await reply(github, context, `${who} claimed this already.${when}`);
return;
}
} else {
// Not claimed, but someone's open PR may already cover it.
const timeline = await getTimeline(github, context, n);
const other = linkedPulls(timeline).find((p) => p.user && p.user.login !== user);
if (other) {
await react(github, context, 'eyes');
await reply(github, context, `#${other.number} already covers this one.`);
return;
}
}
// Free (or lapsed): hand it to the commenter. Clear a lapsed claimant first.
if (assignees.length) {
await github.rest.issues.removeAssignees({ ...repoArgs(context), issue_number: n, assignees });
}
await ensureLabel(github, context);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
try {
await github.rest.issues.addAssignees({
...repoArgs(context),
issue_number: n,
assignees: [user],
});
} catch (e) {
// Assignment can be refused; the label and the reply naming them still stand.
}
await react(github, context, '+1');
await reply(
github,
context,
`Claimed by @${user}. Please open a draft PR early with \`Closes #${n}\`; the claim lapses after ${CLAIM_DAYS} days without visible progress. \`/unclaim\` releases it.`,
);
}
async function unclaim(github, context) {
const issue = context.payload.issue;
const user = context.payload.comment.user.login;
const assignees = (issue.assignees || []).map((a) => a.login);
if (!hasLabel(issue) && assignees.length === 0) {
await react(github, context, '+1'); // nothing to release
return;
}
const allowed = assignees.includes(user) || (await isMaintainer(github, context, user));
if (!allowed) {
await react(github, context, 'eyes');
await reply(github, context, `Only the person who claimed this, or a maintainer, can release it, @${user}.`);
return;
}
await release(github, context, issue, assignees);
await react(github, context, '+1');
await reply(github, context, `Released. This one is free again, so anyone can \`/claim\` it.`);
}
// The comment event.
async function handleComment({ github, context, now = Date.now() }) {
const { issue, comment } = context.payload;
if (!issue || issue.pull_request || !comment) return 'skip';
if (issue.state !== 'open' || issue.locked) return 'skip';
if (comment.user.type === 'Bot' || comment.user.login.endsWith('[bot]')) return 'skip';
const cmd = parseCommand(comment.body);
if (!cmd) return 'skip';
if (cmd === 'claim') await claim(github, context, now);
else await unclaim(github, context);
return cmd;
}
// Issue numbers a PR claims: `#N` in its title (`<summary> (#N)`) and closing keywords in its body.
const MAX_REFS = 5;
function referencedIssues(title, body) {
const found = new Set();
for (const m of String(title || '').matchAll(/(?:^|[\s(])#(\d{1,7})\b/g)) found.add(Number(m[1]));
const closing = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d{1,7})\b/gi;
for (const m of String(body || '').matchAll(closing)) found.add(Number(m[1]));
return [...found].filter((n) => n > 0).slice(0, MAX_REFS);
}
// A pull request was opened: it claims each open-for-contributors issue it references that nobody
// holds yet. The core team's PRs claim nothing (their issues carry `in progress` instead).
async function handlePull({ github, context }) {
const pull = context.payload.pull_request;
if (!pull || pull.state !== 'open') return [];
const author = pull.user && pull.user.login;
if (!author || pull.user.type === 'Bot' || author.endsWith('[bot]')) return [];
if (await isMaintainer(github, context, author)) return [];
const claimed = [];
for (const n of referencedIssues(pull.title, pull.body)) {
if (n === pull.number) continue;
let issue;
try {
({ data: issue } = await github.rest.issues.get({ ...repoArgs(context), issue_number: n }));
} catch (e) {
if (e.status === 404 || e.status === 410) continue;
throw e;
}
if (issue.pull_request || issue.state !== 'open' || issue.locked) continue;
const assignees = (issue.assignees || []).map((a) => a.login);
if (assignees.includes(author)) {
if (!hasLabel(issue)) {
await ensureLabel(github, context);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
}
continue;
}
if (hasLabel(issue, CORE)) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${n} is being worked on by the core team (\`${CORE}\`). Please ask on the issue before going further.`,
});
continue;
}
if (assignees.length || hasLabel(issue)) {
const who = assignees.length ? assignees.map((a) => '@' + a).join(', ') : 'someone else';
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${n} is already claimed by ${who}. If this PR duplicates their work, please close it or offer to help there.`,
});
continue;
}
// An older open PR for the issue wins (lowest number), even if it never recorded its claim.
const timeline = await getTimeline(github, context, n);
const older = linkedPulls(timeline).find((p) => p.number < pull.number && p.user && p.user.login !== author);
if (older) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${older.number} claimed #${n} first (the lowest-numbered open PR wins). Please close this one or offer to help there.`,
});
continue;
}
if (!claimable(issue)) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${n} isn't open for contributors yet (no \`help wanted\` or \`good first issue\` label), so this PR doesn't claim it. A maintainer will reply on the issue.`,
});
continue;
}
await ensureLabel(github, context);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
try {
await github.rest.issues.addAssignees({ ...repoArgs(context), issue_number: n, assignees: [author] });
} catch (e) {
// Assignment can be refused; the label and the comment naming them still stand.
}
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: n,
body: `Claimed by @${author} through #${pull.number}. The claim lapses after ${CLAIM_DAYS} days without pushes.`,
});
claimed.push(n);
}
return claimed;
}
// The daily sweep over open issues labelled `claimed`.
async function sweep({ github, context, now = Date.now() }) {
const issues = await github.paginate(github.rest.issues.listForRepo, {
...repoArgs(context),
state: 'open',
labels: LABEL,
per_page: 100,
});
const cache = new Map();
const released = [];
for (const issue of issues) {
if (issue.pull_request || issue.locked || hasLabel(issue, CORE)) continue;
const assignees = (issue.assignees || []).map((a) => a.login);
let skip = false;
for (const a of assignees) skip = skip || (await isMaintainer(github, context, a, cache));
if (skip) continue;
const status = await claimStatus(github, context, issue, now);
if (!status.release) continue;
await release(github, context, issue, assignees);
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: issue.number,
body:
status.reason === 'no PR'
? `No pull request showed up within ${CLAIM_DAYS} days, so this one is free again. Anyone can \`/claim\` it.`
: `Nothing has been pushed for ${CLAIM_DAYS} days, so this one is free again. Anyone can \`/claim\` it.`,
});
released.push(issue.number);
}
return released;
}
module.exports = {
LABEL,
CORE,
claimable,
parseCommand,
referencedIssues,
isMaintainer,
linkedPulls,
claimStatus,
handleComment,
handlePull,
sweep,
};
+331
View File
@@ -0,0 +1,331 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const claim = require('./claim.js');
const NOW = Date.parse('2026-10-16T12:00:00Z');
const H = 3600 * 1000;
const D = 24 * H;
const ago = (ms) => new Date(NOW - ms).toISOString();
// A fake github-script client that records writes. `world` describes the repo.
function fake(world = {}) {
const calls = [];
const rec = (name) => async (args) => {
calls.push([name, args]);
if (world.fail && world.fail[name]) throw Object.assign(new Error(name), { status: world.fail[name] });
return { data: {} };
};
const github = {
paginate: async (fn, args) => (await fn(args)).data,
rest: {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => {
const permission = (world.perms || {})[username] || 'none';
return { data: { permission } };
},
getCommit: async () => ({ data: { commit: { committer: { date: world.pushed || ago(H) } } } }),
},
pulls: { get: async () => ({ data: { head: { sha: 'abc' } } }) },
reactions: { createForIssueComment: rec('react') },
issues: {
getLabel: async () => {
if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 });
return { data: {} };
},
createLabel: rec('createLabel'),
addLabels: rec('addLabels'),
removeLabel: rec('removeLabel'),
addAssignees: rec('addAssignees'),
removeAssignees: rec('removeAssignees'),
createComment: rec('comment'),
listForRepo: async () => ({ data: world.issues || [] }),
get: async ({ issue_number }) => {
const found = (world.byNumber || {})[issue_number];
if (!found) throw Object.assign(new Error('nf'), { status: 404 });
return { data: found };
},
listEventsForTimeline: async () => ({ data: world.timeline || [] }),
},
},
};
return { github, calls };
}
const names = (calls) => calls.map((c) => c[0]);
const find = (calls, n) => calls.find((c) => c[0] === n);
const labeled = (at) => ({ event: 'labeled', label: { name: 'claimed' }, created_at: at });
const pr = (number, login = 'other', state = 'open') => ({
event: 'cross-referenced',
source: { issue: { number, state, pull_request: {}, user: { login } } },
});
function ctx(body, { assignees = [], labels = ['help wanted'], user = 'alice', extra = {} } = {}) {
return {
repo: { owner: 'tangency-labs', repo: 'fieldcraft' },
payload: {
issue: {
number: 7,
state: 'open',
locked: false,
updated_at: ago(1000 * H),
assignees: assignees.map((login) => ({ login })),
labels: labels.map((name) => ({ name })),
...extra,
},
comment: { id: 99, body, user: { login: user, type: 'User' } },
},
};
}
test('parseCommand', () => {
assert.equal(claim.parseCommand('/claim'), 'claim');
assert.equal(claim.parseCommand(' /claim please'), 'claim');
assert.equal(claim.parseCommand('/unclaim'), 'unclaim');
assert.equal(claim.parseCommand('/claimed'), null);
assert.equal(claim.parseCommand('I would /claim this'), null);
assert.equal(claim.parseCommand(undefined), null);
});
test('claim on a free issue assigns, labels, reacts and replies', async () => {
const { github, calls } = fake({ labelMissing: true });
assert.equal(await claim.handleComment({ github, context: ctx('/claim'), now: NOW }), 'claim');
assert.deepEqual(find(calls, 'addAssignees')[1].assignees, ['alice']);
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['claimed']);
assert.equal(find(calls, 'createLabel')[1].color, '0052cc');
assert.equal(find(calls, 'react')[1].content, '+1');
assert.match(find(calls, 'comment')[1].body, /^Claimed by @alice\. .*`Closes #7`.*14 days/);
});
test('claim still labels and replies when assignment is refused', async () => {
const { github, calls } = fake({ fail: { addAssignees: 422 } });
await claim.handleComment({ github, context: ctx('/claim'), now: NOW });
assert.ok(find(calls, 'addLabels'));
assert.match(find(calls, 'comment')[1].body, /@alice/);
});
test('claim by the current claimant is a quiet thumbs up', async () => {
const { github, calls } = fake();
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['alice'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.deepEqual(names(calls), ['react']);
});
test('claim on an issue claimed by someone else names them and when it lapses', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(10 * H))] });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.equal(find(calls, 'react')[1].content, 'eyes');
const body = find(calls, 'comment')[1].body;
assert.match(body, /@bob/);
assert.match(body, /2026-10-30 02:00 UTC/); // 10 h ago + 14 days
assert.equal(find(calls, 'addAssignees'), undefined);
});
test('a lapsed claim is taken over', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(15 * D))] });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['bob']);
assert.deepEqual(find(calls, 'addAssignees')[1].assignees, ['alice']);
});
test('a claim with a recent PR is not taken over', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(30 * D)), pr(50, 'bob')], pushed: ago(2 * H) });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
});
test('a maintainer\'s claim is never taken over', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(500 * H))], perms: { carol: 'write' } });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['carol'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
assert.equal(find(calls, 'react')[1].content, 'eyes');
});
test('an unclaimed issue with someone else\'s open PR points at the PR', async () => {
const { github, calls } = fake({ timeline: [pr(42, 'bob')] });
await claim.handleComment({ github, context: ctx('/claim'), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
assert.match(find(calls, 'comment')[1].body, /#42/);
});
test('bots, locked issues, closed issues, PRs and other comments are ignored', async () => {
const { github, calls } = fake();
const bot = ctx('/claim');
bot.payload.comment.user = { login: 'dependabot[bot]', type: 'Bot' };
assert.equal(await claim.handleComment({ github, context: bot, now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('/claim', { extra: { locked: true } }), now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('/claim', { extra: { state: 'closed' } }), now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('/claim', { extra: { pull_request: {} } }), now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('hello'), now: NOW }), 'skip');
assert.equal(calls.length, 0);
});
test('unclaim by the claimant releases', async () => {
const { github, calls } = fake();
await claim.handleComment({ github, context: ctx('/unclaim', { assignees: ['alice'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['alice']);
assert.equal(find(calls, 'removeLabel')[1].name, 'claimed');
});
test('unclaim by a maintainer releases, by a stranger does not', async () => {
let w = fake({ perms: { carol: 'admin' } });
await claim.handleComment({ github: w.github, context: ctx('/unclaim', { user: 'carol', assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.ok(find(w.calls, 'removeLabel'));
w = fake();
await claim.handleComment({ github: w.github, context: ctx('/unclaim', { user: 'mallory', assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.equal(find(w.calls, 'removeLabel'), undefined);
assert.equal(find(w.calls, 'react')[1].content, 'eyes');
});
const issue = (number, assignees = ['bob'], extra = []) => ({
number,
locked: false,
updated_at: ago(1000 * H),
assignees: assignees.map((login) => ({ login })),
labels: [{ name: 'claimed' }, ...extra.map((name) => ({ name }))],
});
const repo = { owner: 'tangency-labs', repo: 'fieldcraft' };
test('sweep releases a claim older than 14 days with no PR', async () => {
const { github, calls } = fake({ issues: [issue(7)], timeline: [labeled(ago(15 * D))] });
assert.deepEqual(await claim.sweep({ github, context: { repo }, now: NOW }), [7]);
assert.ok(find(calls, 'removeLabel'));
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['bob']);
assert.match(find(calls, 'comment')[1].body, /free again/);
});
test('sweep keeps a young claim and a claim with a fresh PR, using the latest label time', async () => {
let w = fake({ issues: [issue(7)], timeline: [labeled(ago(30 * D)), labeled(ago(5 * D))] });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
w = fake({ issues: [issue(7)], timeline: [labeled(ago(30 * D)), pr(9)], pushed: ago(3 * D) });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
});
test('sweep releases a claim whose PR has been idle for 14 days', async () => {
const w = fake({ issues: [issue(7)], timeline: [labeled(ago(30 * D)), pr(9)], pushed: ago(15 * D) });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
assert.match(find(w.calls, 'comment')[1].body, /14 days/);
});
test('sweep ignores a closed PR', async () => {
const w = fake({ issues: [issue(7)], timeline: [labeled(ago(15 * D)), pr(9, 'other', 'closed')] });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
});
test('sweep never touches core-team or maintainer-claimed issues', async () => {
const w = fake({
issues: [issue(1, ['bob'], ['in progress']), issue(2, ['carol']), issue(3, ['bob', 'carol'])],
timeline: [labeled(ago(900 * H))],
perms: { carol: 'write' },
});
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
assert.equal(w.calls.length, 0);
});
test('sweep is idempotent: a released issue is no longer listed', async () => {
const world = { issues: [issue(7)], timeline: [labeled(ago(15 * D))] };
const w = fake(world);
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
world.issues = [];
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
});
test('referencedIssues reads the title and closing keywords, capped', () => {
assert.deepEqual(claim.referencedIssues('Fillet: chain (#123)', 'Fixes #9, closes: #10, see #11'), [123, 9, 10]);
assert.deepEqual(claim.referencedIssues('no refs', 'mentions #4 only'), []);
assert.deepEqual(claim.referencedIssues('#1 #2 #3 #4 #5 #6 #7', ''), [1, 2, 3, 4, 5]);
assert.deepEqual(claim.referencedIssues(undefined, null), []);
});
const openIssue = (number, assignees = [], labels = ['help wanted']) => ({
number,
state: 'open',
locked: false,
assignees: assignees.map((login) => ({ login })),
labels: labels.map((name) => ({ name })),
});
const pullCtx = (number, title, body = '', login = 'alice') => ({
repo,
payload: { pull_request: { number, state: 'open', title, body, user: { login, type: 'User' } } },
});
test('a PR referencing a free issue claims it for the author', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7) } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), [7]);
assert.deepEqual(find(calls, 'addAssignees')[1], { ...repo, issue_number: 7, assignees: ['alice'] });
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['claimed']);
assert.match(find(calls, 'comment')[1].body, /Claimed by @alice through #50/);
});
test('a PR on an issue someone else holds comments on the PR and changes nothing', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7, ['bob'], ['help wanted', 'claimed']) } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.equal(find(calls, 'addAssignees'), undefined);
const c = find(calls, 'comment')[1];
assert.equal(c.issue_number, 50);
assert.match(c.body, /@bob/);
});
test('the lowest-numbered open PR wins an unrecorded claim', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7) }, timeline: [pr(42, 'bob')] });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.match(find(calls, 'comment')[1].body, /#42 claimed #7 first/);
});
test('the claimant\'s own PR just makes sure the label is there', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7, ['alice']) } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.deepEqual(names(calls), ['addLabels']);
});
test('PRs from bots, references to PRs, closed and missing issues are ignored', async () => {
const w = fake({
byNumber: { 7: { ...openIssue(7), pull_request: {} }, 8: { ...openIssue(8), state: 'closed' } },
});
assert.deepEqual(await claim.handlePull({ github: w.github, context: pullCtx(50, 'x (#7) (#8) (#9)') }), []);
const bot = pullCtx(51, 'x (#1)');
bot.payload.pull_request.user = { login: 'dependabot[bot]', type: 'Bot' };
assert.deepEqual(await claim.handlePull({ github: w.github, context: bot }), []);
assert.equal(w.calls.length, 0);
});
test('an issue the core team holds (`in progress`) cannot be claimed', async () => {
const { github, calls } = fake();
await claim.handleComment({ github, context: ctx('/claim', { labels: ['help wanted', 'in progress'] }), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
assert.match(find(calls, 'comment')[1].body, /core team/);
});
test('only help wanted / good first issue issues can be claimed, except by a maintainer', async () => {
let w = fake();
await claim.handleComment({ github: w.github, context: ctx('/claim', { labels: ['bug'] }), now: NOW });
assert.equal(find(w.calls, 'addAssignees'), undefined);
assert.match(find(w.calls, 'comment')[1].body, /help wanted/);
w = fake();
await claim.handleComment({ github: w.github, context: ctx('/claim', { labels: ['good first issue'] }), now: NOW });
assert.ok(find(w.calls, 'addAssignees'));
w = fake({ perms: { carol: 'write' } });
await claim.handleComment({ github: w.github, context: ctx('/claim', { user: 'carol', labels: [] }), now: NOW });
assert.deepEqual(find(w.calls, 'addAssignees')[1].assignees, ['carol']);
});
test('a PR on a core-team or not-yet-open issue comments on the PR and claims nothing', async () => {
const { github, calls } = fake({
byNumber: { 7: openIssue(7, [], ['help wanted', 'in progress']), 8: openIssue(8, [], ['bug']) },
});
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7) (#8)') }), []);
assert.equal(find(calls, 'addLabels'), undefined);
const bodies = calls.filter((c) => c[0] === 'comment').map((c) => c[1].body);
assert.match(bodies[0], /core team/);
assert.match(bodies[1], /isn't open for contributors/);
});
test('a maintainer\'s PR claims nothing', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7) }, perms: { alice: 'admin' } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.equal(calls.length, 0);
});
test('a claim whose PR is stale but which was claimed recently stands', async () => {
const w = fake({ issues: [issue(7)], timeline: [labeled(ago(2 * D)), pr(9)], pushed: ago(30 * D) });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
});
+178
View File
@@ -0,0 +1,178 @@
// `needs-info`: a short hand-written bug report without reproduction steps is asked for them once.
// The author's reply (or an edit that adds them) clears the label; the daily sweep closes what
// stays unanswered for INFO_DAYS. Reports filed from the app (Help › Report a Bug,
// crates/engine/src/bug_report.rs) carry their session, crash report and version, so they are
// never asked. Used by .github/workflows/triage.yml.
'use strict';
const { ensureLabel, isMaintainer, labelNames, section } = require('./triage.js');
const LABEL = 'needs-info';
const LABEL_COLOR = 'd4c5f9';
const LABEL_DESCRIPTION = 'waiting on the reporter for reproduction steps';
const INFO_DAYS = 14;
const DAY = 24 * 3600 * 1000;
const MAX_TEXT = 20000;
function repoArgs(context) {
return { owner: context.repo.owner, repo: context.repo.repo };
}
// What only the app's own reports carry (crates/engine/src/bug_report.rs).
const FROM_APP = /Replay the attached session|^Crash report `|^Backtrace \(top\):|^Session \(session\.json\):/m;
// Requests, questions and plans, which aren't reproduced.
const NOT_A_BUG = /^\s*\[(feat|feature|change|enhancement)|feature request|suggestion|\?\s*$|^\s*(tracker|refactor|support|add|allow|how|why|request|missing)\b/i;
// A report this long describes what it saw; only short ones are asked. Counted without the
// form's headings and its `_No response_` placeholders.
const SHORT = 300;
const ATTACHMENT = /user-attachments\/|\.(fieldcraft|solidcraft|inp|bdf|k|vtu|step|stp|3mf|stl|json)\b/i;
const COMMAND = /["`][a-z_]+\.[a-z_][a-z_.]*["`]|\bfieldcraft(-cli)?\s+(--sample|run|snapshot)\b/;
const VERSION = /\bv?\d+\.\d+\.\d+\b|\b[0-9a-f]{7,40}\b/i;
function prose(body) {
return body
.split('\n')
.filter((l) => !/^#{2,3}\s/.test(l) && l.trim() !== '_No response_')
.join(' ')
.replace(/\s+/g, ' ')
.trim();
}
// What a bug report lacks: [] when it can be reproduced.
function missing(title, body) {
const t = String(title || '');
const b = String(body || '').slice(0, MAX_TEXT).replace(/<!--[\s\S]*?-->/g, '');
if (NOT_A_BUG.test(t) || FROM_APP.test(b) || prose(b).length >= SHORT) return [];
const steps = `${section(b, 'Reproduction')}\n${section(b, 'Steps to reproduce')}`.replace(/_No response_/g, '');
const reproducible =
steps.replace(/\s+/g, '').length >= 30 || ATTACHMENT.test(b) || COMMAND.test(b) || /^\s*1[.)]\s/m.test(b);
if (reproducible) return [];
const asks = ['steps'];
if (!VERSION.test(`${t}\n${b}`)) asks.push('version');
return asks;
}
function askBody(asks) {
const lines = ['Thanks for the report! To reproduce it we need a little more:'];
lines.push(
'- **Steps to reproduce**: numbered steps from a sample (`fieldcraft --sample cantilever`) or a script ' +
'(`fieldcraft-cli run script.json`), and the `.fieldcraft` or `.solidcraft` file that shows it if it is your own model. ' +
'**Help › Report a Bug** in the app fills this in for you.',
);
if (asks.includes('version')) lines.push('- **FieldCraft version** (or commit) and your OS.');
lines.push(
'',
`Reply here or edit the issue and it goes back to the triage queue. If we don't hear back within ${INFO_DAYS} days it is closed; reopen it any time.`,
);
return lines.join('\n');
}
// What to ask the reporter of `issue`, or [] (maintainers' and bots' issues are never asked).
async function asksFor(github, context, issue, cache) {
if (issue.state !== 'open' || issue.pull_request || issue.locked) return [];
const names = labelNames(issue);
if (names.includes(LABEL) || names.includes('enhancement')) return []; // the feature form labels its issues
const asks = missing(issue.title, issue.body);
if (!asks.length || !issue.user || issue.user.type === 'Bot') return [];
const login = issue.user.login;
if (!cache.has(login)) cache.set(login, await isMaintainer(github, context, login));
return cache.get(login) ? [] : asks;
}
async function ask(github, context, issue, asks) {
await ensureLabel(github, context, LABEL, LABEL_COLOR, LABEL_DESCRIPTION);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: issue.number, labels: [LABEL] });
await github.rest.issues.createComment({ ...repoArgs(context), issue_number: issue.number, body: askBody(asks) });
}
// issues.opened.
async function handleOpened({ github, context }) {
const issue = context.payload.issue;
if (!issue) return false;
const asks = await asksFor(github, context, issue, new Map());
if (!asks.length) return false;
await ask(github, context, issue, asks);
return true;
}
// Workflow dispatch: the same ask over the open issues filed before the bot. With `dryRun` it
// changes nothing and returns what it would ask.
async function backfill({ github, context, dryRun = true }) {
const issues = await github.paginate(github.rest.issues.listForRepo, {
...repoArgs(context),
state: 'open',
per_page: 100,
});
const cache = new Map();
const plan = [];
for (const issue of issues) {
const asks = await asksFor(github, context, issue, cache);
if (!asks.length) continue;
plan.push({ number: issue.number, title: issue.title, asks });
if (!dryRun) await ask(github, context, issue, asks);
}
return plan;
}
async function clear(github, context, number) {
try {
await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: number, name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
}
}
// issue_comment.created / issues.edited: the reporter answered.
async function handleReply({ github, context }) {
const { issue, comment } = context.payload;
if (!issue || !labelNames(issue).includes(LABEL)) return false;
const author = issue.user && issue.user.login;
const actor = comment ? comment.user.login : context.payload.sender && context.payload.sender.login;
if (!author || actor !== author) return false;
if (!comment && missing(issue.title, issue.body).length) return false; // an edit that still lacks steps
await clear(github, context, issue.number);
return true;
}
// When the label was last added, from the timeline; the issue's update time if it can't be found.
async function labeledAt(github, context, issue) {
const events = await github.paginate(github.rest.issues.listEventsForTimeline, {
...repoArgs(context),
issue_number: issue.number,
per_page: 100,
});
let at = null;
for (const ev of events) if (ev.event === 'labeled' && ev.label && ev.label.name === LABEL) at = ev.created_at;
return new Date(at || issue.updated_at).getTime();
}
// The daily sweep: close what has waited INFO_DAYS for its reporter.
async function sweep({ github, context, now = Date.now() }) {
const issues = await github.paginate(github.rest.issues.listForRepo, {
...repoArgs(context),
state: 'open',
labels: LABEL,
per_page: 100,
});
const closed = [];
for (const issue of issues) {
if (issue.pull_request || issue.locked) continue;
if (now - (await labeledAt(github, context, issue)) < INFO_DAYS * DAY) continue;
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: issue.number,
body: `Closing for now: we couldn't reproduce this without more detail. Reopen it with steps to reproduce and we'll pick it up.`,
});
await github.rest.issues.update({
...repoArgs(context),
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
closed.push(issue.number);
}
return closed;
}
module.exports = { LABEL, missing, handleOpened, handleReply, sweep, backfill };
+145
View File
@@ -0,0 +1,145 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const info = require('./needs-info.js');
const repo = { owner: 'tangency-labs', repo: 'fieldcraft' };
const NOW = Date.parse('2026-10-18T12:00:00Z');
const DAY = 24 * 3600 * 1000;
const ago = (ms) => new Date(NOW - ms).toISOString();
function fake(world = {}) {
const calls = [];
const rec = (name) => async (args) => {
calls.push([name, args]);
return { data: {} };
};
const github = {
paginate: async (fn, args) => (await fn(args)).data,
rest: {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => ({
data: { permission: (world.perms || {})[username] || 'none' },
}),
},
issues: {
getLabel: async () => ({ data: {} }),
createLabel: rec('createLabel'),
addLabels: rec('addLabels'),
removeLabel: rec('removeLabel'),
createComment: rec('comment'),
update: rec('update'),
listForRepo: async () => ({ data: world.issues || [] }),
listEventsForTimeline: async () => ({ data: world.timeline || [] }),
},
},
};
return { github, calls };
}
const find = (calls, n) => calls.find((c) => c[0] === n);
const labels = (names) => names.map((name) => ({ name }));
const long = 'x'.repeat(400);
const form = (observed, reproduction, version = '_No response_') =>
`### Observed failure\n\n${observed}\n\n### Reproduction\n\n${reproduction}\n\n### Evidence\n\n_No response_\n\n### Expected behaviour\n\nworks\n\n### Version and platform\n\n${version}`;
test('missing: short reports without steps are asked, with the version when it is absent', () => {
assert.deepEqual(info.missing('Solve does not work', 'The solver is not working.'), ['steps', 'version']);
assert.deepEqual(info.missing('Solve does not work', 'Broken on v0.6.0.'), ['steps']);
assert.deepEqual(info.missing('Hex mesh broken', form('it fails', 'mesh it')), ['steps', 'version']);
assert.deepEqual(info.missing('Hex mesh broken', form('it fails', 'mesh it', 'v0.6.0, macOS')), ['steps']);
});
test('missing: reproducible, long, app-filed and non-bug issues are left alone', () => {
assert.deepEqual(info.missing('Mesh fails', form('x', '1. fieldcraft --sample cantilever\n2. Mesh ▸ Tet')), []);
assert.deepEqual(info.missing('Mesh fails', 'see bracket.fieldcraft attached'), []);
assert.deepEqual(info.missing('Mesh fails', 'runs `mesh.tet.generate` and fails'), []);
assert.deepEqual(info.missing('Mesh fails', long), []);
assert.deepEqual(info.missing('Internal error in solve.static', form('x', 'Replay the attached session (3 command(s)): `fieldcraft-cli run session.json`.')), []);
assert.deepEqual(info.missing('FieldCraft closed unexpectedly', form('x', '', 'v0.6.0') + '\nCrash report `abc` (panic), seen 1 time(s)'), []);
assert.deepEqual(info.missing('Feature request: dark mode', 'please'), []);
assert.deepEqual(info.missing('[FEAT] composites', 'please'), []);
assert.deepEqual(info.missing('How do I apply a pressure?', ''), []);
});
const opened = (title, body, login = 'reporter') => ({
repo,
payload: { issue: { number: 4, state: 'open', title, body, labels: [], user: { login, type: 'User' } } },
});
test('an opened thin report is labelled and asked once', async () => {
const { github, calls } = fake();
assert.equal(await info.handleOpened({ github, context: opened('Solve broken', 'does not work') }), true);
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['needs-info']);
const body = find(calls, 'comment')[1].body;
assert.match(body, /Steps to reproduce/);
assert.match(body, /FieldCraft version/);
assert.match(body, /14 days/);
});
test('a maintainer\'s note is never asked', async () => {
const w = fake({ perms: { jyviko: 'admin' } });
assert.equal(await info.handleOpened({ github: w.github, context: opened('Solve broken', 'x', 'jyviko') }), false);
assert.equal(w.calls.length, 0);
});
const reply = (login, { comment = true, body = 'x', issueLabels = ['needs-info'] } = {}) => ({
repo,
payload: {
issue: { number: 4, title: 'Solve broken', body, labels: labels(issueLabels), user: { login: 'reporter' } },
...(comment ? { comment: { user: { login } } } : { sender: { login } }),
},
});
test('the reporter\'s comment clears needs-info; someone else\'s does not', async () => {
let w = fake();
assert.equal(await info.handleReply({ github: w.github, context: reply('reporter') }), true);
assert.equal(find(w.calls, 'removeLabel')[1].name, 'needs-info');
w = fake();
assert.equal(await info.handleReply({ github: w.github, context: reply('bystander') }), false);
w = fake();
assert.equal(await info.handleReply({ github: w.github, context: reply('reporter', { issueLabels: [] }) }), false);
assert.equal(w.calls.length, 0);
});
test('an edit clears needs-info only once the steps are there', async () => {
let w = fake();
assert.equal(await info.handleReply({ github: w.github, context: reply('reporter', { comment: false, body: 'still broken' }) }), false);
w = fake();
const fixed = reply('reporter', { comment: false, body: '## Reproduction\n1. open the cantilever sample\n2. solve it' });
assert.equal(await info.handleReply({ github: w.github, context: fixed }), true);
});
test('the sweep closes what waited 14 days, and keeps younger ones', async () => {
const issue = { number: 4, locked: false, updated_at: ago(30 * DAY), labels: labels(['needs-info']) };
const labeled = (at) => ({ event: 'labeled', label: { name: 'needs-info' }, created_at: at });
let w = fake({ issues: [issue], timeline: [labeled(ago(15 * DAY))] });
assert.deepEqual(await info.sweep({ github: w.github, context: { repo }, now: NOW }), [4]);
assert.deepEqual(find(w.calls, 'update')[1], { ...repo, issue_number: 4, state: 'closed', state_reason: 'not_planned' });
w = fake({ issues: [issue], timeline: [labeled(ago(20 * DAY)), labeled(ago(3 * DAY))] });
assert.deepEqual(await info.sweep({ github: w.github, context: { repo }, now: NOW }), []);
assert.equal(w.calls.length, 0);
});
test('backfill asks open thin reports once, and the dry run writes nothing', async () => {
const issues = [
{ number: 1, state: 'open', title: 'Solve broken', body: 'nope', labels: [], user: { login: 'a', type: 'User' } },
{ number: 2, state: 'open', title: 'Solve broken', body: 'nope', labels: labels(['needs-info']), user: { login: 'a' } },
{ number: 3, state: 'open', title: 'x', body: 'nope', labels: [], pull_request: {}, user: { login: 'a' } },
];
let w = fake({ issues });
const plan = await info.backfill({ github: w.github, context: { repo }, dryRun: true });
assert.deepEqual(plan.map((p) => p.number), [1]);
assert.equal(w.calls.length, 0);
w = fake({ issues });
await info.backfill({ github: w.github, context: { repo }, dryRun: false });
assert.equal(find(w.calls, 'addLabels')[1].issue_number, 1);
});
test('an issue from the feature form is never asked for steps', async () => {
const { github, calls } = fake();
const ctx = opened('Dark mode', '### What you need and why\nwork at night');
ctx.payload.issue.labels = labels(['enhancement']);
assert.equal(await info.handleOpened({ github, context: ctx }), false);
assert.equal(calls.length, 0);
});
+315
View File
@@ -0,0 +1,315 @@
// The triage bot's logic, used by .github/workflows/triage.yml through actions/github-script.
// New issues and PRs get `area:*` and type labels from fixed rules; an issue nobody has given a
// priority yet carries `needs-triage`. Maintainers set labels with `/triage`. The rules only ever
// add labels, so a person's correction is never undone.
// Everything takes the github-script `github` client and `context`, so the tests can pass fakes.
'use strict';
const NEEDS_TRIAGE = 'needs-triage';
const NEEDS_TRIAGE_COLOR = 'ededed';
const NEEDS_TRIAGE_DESCRIPTION = 'no priority yet; a maintainer sorts it with /triage';
const MAX_TOKENS = 10;
const MAX_TEXT = 20000;
// Changed paths of a PR → area. First match wins per file.
const PATH_AREAS = [
[/^crates\/mesh\//, 'area:mesh'],
[/^crates\/lbm[^/]*\//, 'area:cfd'],
[/^crates\/(solver|fem|explicit)\//, 'area:solver'],
[/^(crates\/ui-egui|apps\/[^/]+)\//, 'area:ui'],
[/^crates\/(io|decks)\//, 'area:io'],
[/^(docs\/|[^/]+\.md$)/, 'area:docs'],
[/^(\.github\/|xtask\/|packaging\/|scripts\/|tools\/)/, 'area:infra'],
];
// Command id prefix (the in-app reports quote it: "Internal error in mesh.tet.generate") → area.
const COMMAND_AREAS = {
mesh: 'area:mesh',
geometry: 'area:mesh',
model: 'area:solver',
interaction: 'area:solver',
solve: 'area:solver',
file: 'area:io',
deck: 'area:io',
results: 'area:ui',
view: 'area:ui',
};
// Words in an issue's title → area. Conservative: a miss leaves the issue for a person.
const WORD_AREAS = [
[/\b(mesh(es|ing|er)?|tet(rahedral|s)?|hex(ahedral|es)?|octree|jacobian|sliver|element quality|remesh)\b/i, 'area:mesh'],
[/\b(lbm|lattice boltzmann|cfd|fluid|flow|turbulen(ce|t)|velocity field)\b/i, 'area:cfd'],
[/\b(solver?|solves?|solving|explicit|implicit|static|frequency|modal|buckling|contact|plasticity|hyperelastic|convergence|newton|material|thermal)\b/i, 'area:solver'],
[/\b(abaqus|nastran|ls-?dyna|deck|vtk|vtu|paraview|gmsh|step|stl|3mf|csv|import|export)\b/i, 'area:io'],
[/\b(toolbar|dialog|viewport|panel|button|shortcut|menu|window|legend|plot|theme)\b/i, 'area:ui'],
[/\b(docs?|documentation|readme|tutorial|typo)\b/i, 'area:docs'],
[/\b(ci|release|packaging|flatpak|installer|homebrew|winget)\b/i, 'area:infra'],
];
const CRASH = /^\s*crash\b|\bpanic(ked)?\b|^\s*internal error\b|\bclosed unexpectedly\b/i;
const FEATURE = /^\s*\[?feature( request)?\]?\b/i;
// The issue forms' own headings (.github/ISSUE_TEMPLATE): their labels are set by GitHub when the
// form is used, so these only matter for the backfill and for issues edited into shape.
const BUG_FORM = /^###\s*observed failure\s*$/im;
const FEATURE_FORM = /^###\s*what you need and why\s*$/im;
function repoArgs(context) {
return { owner: context.repo.owner, repo: context.repo.repo };
}
// The text under a `## <name>` heading (`###` in issue forms), up to the next heading.
function section(body, name) {
const lines = body.split('\n');
const start = lines.findIndex((l) => new RegExp(`^#{2,3}\\s*${name}\\s*$`, 'i').test(l.trim()));
if (start < 0) return '';
const rest = lines.slice(start + 1);
const end = rest.findIndex((l) => /^#{2,3}\s/.test(l));
return (end < 0 ? rest : rest.slice(0, end)).join('\n');
}
function labelNames(item) {
return (item.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
}
function isPriority(name) {
return /^priority\s*:/.test(name);
}
function hasPriority(names) {
return names.some(isPriority);
}
// Labels for an issue from its title and body.
function classifyIssue(title, body) {
const t = String(title || '').slice(0, 500);
const b = String(body || '').slice(0, MAX_TEXT);
const labels = new Set();
if (CRASH.test(t) || BUG_FORM.test(b)) {
labels.add('bug');
} else if (FEATURE.test(t) || FEATURE_FORM.test(b)) {
labels.add('enhancement');
}
// The title names the feature; a command id in the title (the in-app report's) is the fallback.
// The body is not read: a report's session quotes every earlier command too.
let area = null;
for (const [re, a] of WORD_AREAS) {
if (re.test(t)) {
area = a;
break;
}
}
if (!area) {
const m = /\b([a-z_]+)\.[a-z_]+(\.[a-z_]+)*\b/.exec(t);
area = (m && COMMAND_AREAS[m[1]]) || null;
}
if (area) labels.add(area);
return [...labels];
}
// Labels for a PR from its changed paths.
function classifyPaths(paths) {
const labels = new Set();
for (const p of paths) {
for (const [re, area] of PATH_AREAS) {
if (re.test(p)) {
labels.add(area);
break;
}
}
}
return [...labels].sort();
}
async function ensureLabel(github, context, name, color, description) {
try {
await github.rest.issues.getLabel({ ...repoArgs(context), name });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({ ...repoArgs(context), name, color, description });
} catch (e2) {
if (e2.status !== 422) throw e2; // created meanwhile
}
}
}
async function pullPaths(github, context, number) {
const files = await github.paginate(github.rest.pulls.listFiles, {
...repoArgs(context),
pull_number: number,
per_page: 100,
});
return files.map((f) => f.filename);
}
// The labels the rules would add to an issue or PR that it doesn't already carry.
async function proposal(github, context, item) {
const current = labelNames(item);
let wanted;
if (item.pull_request) {
wanted = classifyPaths(await pullPaths(github, context, item.number));
} else {
wanted = classifyIssue(item.title, item.body);
if (!hasPriority(current) && !hasPriority(wanted)) wanted.push(NEEDS_TRIAGE);
}
return wanted.filter((l) => !current.includes(l));
}
async function apply(github, context, number, labels) {
if (!labels.length) return;
if (labels.includes(NEEDS_TRIAGE)) {
await ensureLabel(github, context, NEEDS_TRIAGE, NEEDS_TRIAGE_COLOR, NEEDS_TRIAGE_DESCRIPTION);
}
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: number, labels });
}
// issues.opened / pull_request_target.opened.
async function handleOpened({ github, context }) {
const issue = context.payload.issue;
const pull = context.payload.pull_request;
const item = issue || (pull && { ...pull, pull_request: {} });
if (!item || item.state !== 'open') return [];
const labels = await proposal(github, context, item);
await apply(github, context, item.number, labels);
return labels;
}
// issues.labeled: a priority settles triage.
async function handleLabeled({ github, context }) {
const issue = context.payload.issue;
const label = context.payload.label;
if (!issue || !label || !isPriority(label.name)) return false;
if (!labelNames(issue).includes(NEEDS_TRIAGE)) return false;
try {
await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: issue.number, name: NEEDS_TRIAGE });
} catch (e) {
if (e.status !== 404) throw e;
}
return true;
}
// `/triage area:mesh high S bug -enhancement`: tokens name existing labels, matched without
// regard to case, `-`/`_` for spaces or the space after a colon (`in-progress`, `size:S`); `high`
// is short for `priority: high` and `S`/`M`/`L` for `size: S`…, each replacing any other label of
// its kind; a leading `-` removes.
const ALIASES = { high: 'priority: high', s: 'size: S', m: 'size: M', l: 'size: L' };
function parseTriage(body) {
const line = String(body || '').trimStart().split('\n')[0];
const m = /^\/triage(\s.*)?$/.exec(line.trimEnd());
if (!m) return null;
const add = [];
const remove = [];
for (const raw of (m[1] || '').trim().split(/\s+/).filter(Boolean).slice(0, MAX_TOKENS)) {
const minus = raw.startsWith('-');
let name = minus ? raw.slice(1) : raw;
name = ALIASES[name.toLowerCase()] || name;
if (name) (minus ? remove : add).push(name);
}
return { add, remove };
}
function normalize(name) {
return String(name).toLowerCase().replace(/[-_\s]+/g, ' ').replace(/\s*:\s*/g, ':').trim();
}
// The label kind that replaces its siblings: `priority` or `size`.
function kind(name) {
const m = /^(priority|size)\s*:/.exec(name);
return m ? m[1] : null;
}
async function isMaintainer(github, context, login) {
if (login === context.repo.owner) return true;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...repoArgs(context), username: login });
return ['admin', 'write', 'maintain'].includes(data.permission);
} catch (e) {
return false;
}
}
async function react(github, context, content) {
await github.rest.reactions.createForIssueComment({
...repoArgs(context),
comment_id: context.payload.comment.id,
content,
});
}
// issue_comment.created.
async function handleComment({ github, context }) {
const { issue, comment } = context.payload;
if (!issue || !comment || comment.user.type === 'Bot' || comment.user.login.endsWith('[bot]')) return 'skip';
const cmd = parseTriage(comment.body);
if (!cmd) return 'skip';
if (!(await isMaintainer(github, context, comment.user.login))) {
await react(github, context, 'eyes');
return 'denied';
}
const known = new Map(
(await github.paginate(github.rest.issues.listLabelsForRepo, { ...repoArgs(context), per_page: 100 })).map((l) => [
normalize(l.name),
l.name,
]),
);
const resolve = (n) => known.get(normalize(n));
const unknown = [...cmd.add, ...cmd.remove].filter((n) => !resolve(n));
if (unknown.length) {
await react(github, context, 'confused');
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: issue.number,
body: `No such label: ${unknown.map((n) => '`' + n + '`').join(', ')}. Nothing was changed.`,
});
return 'unknown';
}
const addNames = cmd.add.map(resolve);
const current = labelNames(issue);
const remove = new Set(cmd.remove.map(resolve));
for (const k of new Set(addNames.map(kind).filter(Boolean))) {
for (const n of current) if (kind(n) === k && !addNames.includes(n)) remove.add(n);
}
if (addNames.length || remove.size) remove.add(NEEDS_TRIAGE);
for (const name of remove) {
if (!current.includes(name)) continue;
await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: issue.number, name });
}
const add = addNames.filter((n) => !current.includes(n));
if (add.length) await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: issue.number, labels: add });
await react(github, context, '+1');
return 'triage';
}
// Workflow dispatch: the rules over every open issue and PR with no labels at all. With `dryRun`
// it changes nothing and returns what it would do.
async function backfill({ github, context, dryRun = true }) {
const items = await github.paginate(github.rest.issues.listForRepo, {
...repoArgs(context),
state: 'open',
per_page: 100,
});
const plan = [];
for (const item of items) {
if (labelNames(item).length || item.locked) continue;
const labels = await proposal(github, context, item);
plan.push({ number: item.number, pull: Boolean(item.pull_request), title: item.title, labels });
if (!dryRun) await apply(github, context, item.number, labels);
}
return plan;
}
module.exports = {
NEEDS_TRIAGE,
classifyIssue,
classifyPaths,
ensureLabel,
isMaintainer,
labelNames,
section,
parseTriage,
handleOpened,
handleLabeled,
handleComment,
backfill,
};
+202
View File
@@ -0,0 +1,202 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const triage = require('./triage.js');
const repo = { owner: 'tangency-labs', repo: 'fieldcraft' };
// A fake github-script client that records writes. `world` describes the repo.
function fake(world = {}) {
const calls = [];
const rec = (name) => async (args) => {
calls.push([name, args]);
return { data: {} };
};
const github = {
paginate: async (fn, args) => (await fn(args)).data,
rest: {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => ({
data: { permission: (world.perms || {})[username] || 'none' },
}),
},
pulls: { listFiles: async () => ({ data: (world.files || []).map((filename) => ({ filename })) }) },
reactions: { createForIssueComment: rec('react') },
issues: {
getLabel: async () => {
if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 });
return { data: {} };
},
createLabel: rec('createLabel'),
addLabels: rec('addLabels'),
removeLabel: rec('removeLabel'),
createComment: rec('comment'),
listForRepo: async () => ({ data: world.items || [] }),
listLabelsForRepo: async () => ({
data: (world.labels || ['bug', 'enhancement', 'area:mesh', 'priority: high', 'size: S', 'size: M', 'in progress', 'needs-triage']).map(
(name) => ({ name }),
),
}),
},
},
};
return { github, calls };
}
const find = (calls, n) => calls.find((c) => c[0] === n);
const all = (calls, n) => calls.filter((c) => c[0] === n);
const labels = (names) => names.map((name) => ({ name }));
test('classifyIssue: in-app reports get bug and the area from the title or its command', () => {
assert.deepEqual(triage.classifyIssue('Internal error in mesh.tet.generate', ''), ['bug', 'area:mesh']);
assert.deepEqual(triage.classifyIssue('FieldCraft closed unexpectedly in solve.static', ''), ['bug', 'area:solver']);
assert.deepEqual(triage.classifyIssue('Internal error in results.show', ''), ['bug', 'area:ui']);
assert.deepEqual(triage.classifyIssue('Crash on startup', ''), ['bug']);
});
test('classifyIssue: the forms give the type, the title the area', () => {
const bug = '### Observed failure\nwrong\n\n### Reproduction\n1. x\n';
assert.deepEqual(triage.classifyIssue('Lattice Boltzmann diverges at Re 200', bug), ['bug', 'area:cfd']);
const feat = '### Current behaviour\nnone\n\n### What you need and why\nVTU\n';
assert.deepEqual(triage.classifyIssue('ParaView export', feat), ['enhancement', 'area:io']);
assert.deepEqual(triage.classifyIssue('Feature request: dark theme', ''), ['enhancement', 'area:ui']);
assert.deepEqual(triage.classifyIssue('Hex meshing of thin walls', ''), ['area:mesh']);
assert.deepEqual(triage.classifyIssue('Typo in the docs', ''), ['area:docs']);
});
test('classifyIssue: the body\'s session never gives the area, unmatched text gets nothing', () => {
assert.deepEqual(triage.classifyIssue('Wrong numbers', '"mesh.tet.generate"\n"solve.static"'), []);
assert.deepEqual(triage.classifyIssue('Refactor with types', 'words'), []);
assert.deepEqual(triage.classifyIssue(undefined, undefined), []);
});
test('classifyPaths maps changed files to areas', () => {
assert.deepEqual(
triage.classifyPaths([
'crates/mesh/src/a.rs',
'crates/lbm-gpu/b.rs',
'crates/fem/c.rs',
'apps/fieldcraft-cli/d.rs',
'crates/decks/e.rs',
'docs/oracle.md',
'README.md',
'.github/workflows/ci.yml',
'Cargo.lock',
]),
['area:cfd', 'area:docs', 'area:infra', 'area:io', 'area:mesh', 'area:solver', 'area:ui'],
);
assert.deepEqual(triage.classifyPaths(['crates/engine/src/cmd/x.rs']), []);
});
test('parseTriage', () => {
assert.deepEqual(triage.parseTriage('/triage area:mesh high S -enhancement'), {
add: ['area:mesh', 'priority: high', 'size: S'],
remove: ['enhancement'],
});
assert.deepEqual(triage.parseTriage('/triage'), { add: [], remove: [] });
assert.equal(triage.parseTriage('/triaged'), null);
assert.equal(triage.parseTriage('please /triage'), null);
assert.equal(triage.parseTriage('/triage ' + 'bug '.repeat(50)).add.length, 10);
});
test('an opened issue gets its labels plus needs-triage, which is created if missing', async () => {
const { github, calls } = fake({ labelMissing: true });
const issue = { number: 5, state: 'open', title: 'Internal error in mesh.hex.octree', body: '', labels: [] };
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), [
'bug',
'area:mesh',
'needs-triage',
]);
assert.ok(find(calls, 'createLabel'));
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['bug', 'area:mesh', 'needs-triage']);
});
test('an opened issue keeps the labels it was filed with and skips needs-triage with a priority', async () => {
const { github, calls } = fake();
const issue = { number: 5, state: 'open', title: 'Solver diverges', body: '', labels: labels(['bug', 'priority: high']) };
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), ['area:solver']);
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['area:solver']);
});
test('an opened PR is labelled from its paths and never gets needs-triage', async () => {
const { github, calls } = fake({ files: ['crates/mesh/src/tet.rs'] });
const pull_request = { number: 9, state: 'open', title: 'Mesh: thing', body: '', labels: [] };
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { pull_request } } }), ['area:mesh']);
assert.deepEqual(find(calls, 'addLabels')[1], { ...repo, issue_number: 9, labels: ['area:mesh'] });
});
test('a priority label clears needs-triage', async () => {
const { github, calls } = fake();
const issue = { number: 5, labels: labels(['needs-triage', 'priority: high']) };
assert.equal(await triage.handleLabeled({ github, context: { repo, payload: { issue, label: { name: 'priority: high' } } } }), true);
assert.equal(find(calls, 'removeLabel')[1].name, 'needs-triage');
const other = fake();
assert.equal(
await triage.handleLabeled({ github: other.github, context: { repo, payload: { issue, label: { name: 'bug' } } } }),
false,
);
assert.equal(other.calls.length, 0);
});
const comment = (body, login = 'carol', issueLabels = []) => ({
repo,
payload: {
issue: { number: 5, labels: labels(issueLabels) },
comment: { id: 1, body, user: { login, type: 'User' } },
},
});
test('/triage by a maintainer sets labels, replaces the size and clears needs-triage', async () => {
const { github, calls } = fake({ perms: { jyviko: 'admin' } });
const ctx = comment('/triage area:mesh HIGH S', 'jyviko', ['size: M', 'needs-triage', 'bug']);
assert.equal(await triage.handleComment({ github, context: ctx }), 'triage');
assert.deepEqual(all(calls, 'removeLabel').map((c) => c[1].name).sort(), ['needs-triage', 'size: M']);
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['area:mesh', 'priority: high', 'size: S']);
assert.equal(find(calls, 'react')[1].content, '+1');
});
test('/triage matches labels with spaces written with dashes', async () => {
const { github, calls } = fake({ perms: { jyviko: 'admin' } });
assert.equal(await triage.handleComment({ github, context: comment('/triage -in-progress', 'jyviko', ['in progress']) }), 'triage');
assert.equal(find(calls, 'removeLabel')[1].name, 'in progress');
});
test('/triage from a non-maintainer or with an unknown label changes nothing', async () => {
let w = fake();
assert.equal(await triage.handleComment({ github: w.github, context: comment('/triage bug', 'mallory') }), 'denied');
assert.equal(find(w.calls, 'addLabels'), undefined);
w = fake({ perms: { carol: 'admin' } });
assert.equal(await triage.handleComment({ github: w.github, context: comment('/triage area:nope') }), 'unknown');
assert.equal(find(w.calls, 'addLabels'), undefined);
assert.match(find(w.calls, 'comment')[1].body, /area:nope/);
});
test('bots and other comments are ignored', async () => {
const { github, calls } = fake();
const bot = comment('/triage bug');
bot.payload.comment.user = { login: 'x[bot]', type: 'Bot' };
assert.equal(await triage.handleComment({ github, context: bot }), 'skip');
assert.equal(await triage.handleComment({ github, context: comment('hello') }), 'skip');
assert.equal(calls.length, 0);
});
test('backfill only touches unlabelled items, and the dry run writes nothing', async () => {
const items = [
{ number: 1, title: 'Internal error in mesh.tet.generate', body: '', labels: [] },
{ number: 2, title: 'Already sorted', body: '', labels: labels(['bug']) },
{ number: 3, title: 'x', body: '', labels: [], pull_request: {} },
];
let w = fake({ items, files: ['crates/io/src/step.rs'] });
const plan = await triage.backfill({ github: w.github, context: { repo }, dryRun: true });
assert.deepEqual(
plan.map((p) => [p.number, p.labels]),
[
[1, ['bug', 'area:mesh', 'needs-triage']],
[3, ['area:io']],
],
);
assert.equal(w.calls.length, 0);
w = fake({ items, files: ['crates/io/src/step.rs'] });
await triage.backfill({ github: w.github, context: { repo }, dryRun: false });
assert.deepEqual(all(w.calls, 'addLabels').map((c) => c[1].issue_number), [1, 3]);
});
+110
View File
@@ -0,0 +1,110 @@
# NAFEMS LE2 cylindrical shell bending patch test (MITC4 shells)
Our model of the published problem: a 30° sector of a cylindrical shell of mid-surface radius
1000 mm, axial length 500 mm and thickness 10 mm, E = 210 GPa, ν = 0.3 (N, mm, MPa). The straight
edge AB is clamped, the curved edges AD and BC keep their axial displacement (planes of symmetry
across the axis), and the other straight edge DC carries the load. Two load cases, one step each:
1. **Moment**: a uniform edge moment of 1000 N·mm/mm on DC about the axis;
2. **Pressure**: 0.6 MPa outward on the mid-surface, with a hoop traction of 60 MPa on DC (600 N/mm
over the thickness), the membrane force that balances it.
Both put the patch in a uniform state with a hoop stress of 60 MPa on the outer surface (6M/t² in
bending, pR/t in membrane), the published target at E. The descriptions we read do not place E;
since the exact stress is the same everywhere on the patch, we read it at the patch centre
(250, 0, 1000), the node on the y = 0 plane where the hoop direction is the global y axis, so the
tangential stress is σ_yy.
## The mesh
The patch is curved, so the recipe writes its 81 nodes and 64 quad4 elements one by one with
`mesh.node.add` and `mesh.element.add` ([docs/verification.md](../../../docs/verification.md)).
The axis is x and the arc runs from φ = −15° (AB) to φ = 15° (DC): node 1 + i + 9 j lies at
(500 i/8, 1000 sin φ_j, 1000 cos φ_j) with φ_j = −15° + 30° j/8, and element 1 + i + 8 j is
[node(i, j), node(i + 1, j), node(i + 1, j + 1), node(i, j + 1)], so its normal points away from
the axis and the shell's top surface is the outer one (the surface nodal stresses report).
The edge moment and edge traction are written as consistent nodal loads on DC: an end node of the
edge takes half of an inner node's share (two loads with `"distribution": "per_node"`). The
pressure acts on the elements' inner faces (face 2, the reversed node order), pushing outward.
The recipe was generated with this script (`python3 le2.py 8 > recipe.json`):
```python
import json, math, sys
def le2(n):
"""NAFEMS LE2 cylindrical patch (mm, N, MPa): R = 1000, axial length 500, 30° arc from φ = −15° (AB) to 15° (DC)."""
m = n # elements round the arc, as many as along the axis
R, L, half = 1000.0, 500.0, math.radians(15.0)
label = lambda i, j: 1 + i + (n + 1) * j
commands = []
for j in range(m + 1):
for i in range(n + 1):
phi = -half + 2 * half * j / m
commands.append({"command": "mesh.node.add", "params": {"label": label(i, j), "xyz": [L * i / n, R * math.sin(phi), R * math.cos(phi)]}})
for j in range(m):
for i in range(n):
# i along the axis, j round the arc: e3 points away from the axis, so top is the outer surface
nodes = [label(i, j), label(i + 1, j), label(i + 1, j + 1), label(i, j + 1)]
commands.append({"command": "mesh.element.add", "params": {"label": 1 + i + n * j, "kind": "quad4", "nodes": nodes}})
h = L / n
ends = [label(0, m), label(n, m)]
inner = [label(i, m) for i in range(1, n)]
t = [0.0, math.cos(half), -math.sin(half)] # the hoop direction at DC, away from AB
return commands + [
{"command": "model.set.create", "params": {"name": "patch", "elements": list(range(1, n * m + 1))}},
{"command": "model.set.create", "params": {"name": "inside", "faces": [{"element": e, "face": 2} for e in range(1, n * m + 1)]}},
{"command": "model.set.create", "params": {"name": "AB", "nodes": [label(i, 0) for i in range(n + 1)]}},
{"command": "model.set.create", "params": {"name": "AD_BC", "nodes": sorted([label(0, j) for j in range(m + 1)] + [label(n, j) for j in range(m + 1)])}},
{"command": "model.set.create", "params": {"name": "DC_ends", "nodes": ends}},
{"command": "model.set.create", "params": {"name": "DC_inner", "nodes": inner}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "patch", "set": "patch", "material": "Steel", "kind": {"type": "shell", "thickness": 10.0}}},
{"command": "model.boundary_condition.create", "params": {"name": "AB", "type": "displacement", "target": {"set": "AB"}, "values": [0, 0, 0, 0, 0, 0]}},
{"command": "model.boundary_condition.create", "params": {"name": "AD_BC", "type": "symmetry", "target": {"set": "AD_BC"}, "plane": "x"}},
# load case 1: an edge moment of 1000 N·mm/mm on DC about the axis, as consistent nodal moments
{"command": "model.load.create", "params": {"name": "moment_ends", "type": "moment", "target": {"set": "DC_ends"}, "vector": [-1000.0 * h / 2, 0, 0], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "moment_inner", "type": "moment", "target": {"set": "DC_inner"}, "vector": [-1000.0 * h, 0, 0], "distribution": "per_node"}},
# load case 2: 0.6 MPa outward on the mid-surface, 60 MPa × 10 mm hoop traction on DC
{"command": "model.load.create", "params": {"name": "pressure", "type": "pressure", "set": "inside", "magnitude": 0.6}},
{"command": "model.load.create", "params": {"name": "pull_ends", "type": "force", "target": {"set": "DC_ends"}, "vector": [0.0, 600.0 * h / 2 * t[1], 600.0 * h / 2 * t[2]], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "pull_inner", "type": "force", "target": {"set": "DC_inner"}, "vector": [0.0, 600.0 * h * t[1], 600.0 * h * t[2]], "distribution": "per_node"}},
{"command": "model.step.create", "params": {"name": "Moment", "procedure": {"type": "static"}, "boundary_conditions": ["AB", "AD_BC"], "loads": ["moment_ends", "moment_inner"]}},
{"command": "model.step.create", "params": {"name": "Pressure", "procedure": {"type": "static"}, "boundary_conditions": ["AB", "AD_BC"], "loads": ["pressure", "pull_ends", "pull_inner"]}},
{"command": "solve.run", "params": {}, "id": "solve"},
]
n = int(sys.argv[1]) if len(sys.argv) > 1 else 8
print("[\n " + ",\n ".join(json.dumps(c) for c in le2(n)) + "\n]")
```
## Refinement
Every row is the listing above with the row's divisions, solved and probed at E as the case
does; the case mesh is 8 × 8:
| cells | σ_yy at E, moment | error | σ_yy at E, pressure | error |
|---|---|---|---|---|
| 4 × 4 | 59.743 | −0.43% | 59.615 | −0.64% |
| 8 × 8 (this case) | 59.936 | −0.11% | 59.904 | −0.16% |
| 16 × 16 | 59.984 | −0.03% | 59.976 | −0.04% |
| 32 × 32 | 59.996 | −0.01% | 59.994 | −0.01% |
The error falls as the square of the facet angle: each flat element is tilted by half its arc
angle from the hoop direction at E, so a share sin²(Δφ/2) of the hoop stress shows in σ_zz instead
(σ_yy + σ_zz is 60.000 in every row of load case 1). The next refinement of the case mesh moves the
value by 0.05% (moment) and 0.07% (pressure), well under a tenth of the 1% tolerance.
## Tolerance
1%: the target is exact for a thin shell and FieldCraft converges to it; the 8 × 8 mesh carries
a 0.1–0.2% facet error, a fifth of the tolerance or less. `fixture.json` holds the 8 × 8 values:
the mesh is written by the recipe, so it is bit-identical on every platform.
## Running it
```sh
cargo xtask oracle --case nafems-le2
cargo xtask oracle --bless nafems-le2 # only after the case passes
```
+23
View File
@@ -0,0 +1,23 @@
{
"title": "NAFEMS LE2 cylindrical shell bending patch test (MITC4 shells)",
"kind": "benchmark",
"source": "NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE2 (target: tangential stress 60 MPa on the outer surface at E, both load cases); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.2 'LE2: Cylindrical shell bending patch test', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 8",
"quantities": [
{
"id": "stt_E_moment",
"quantity": "tangential stress at E, outer surface, load case 1 (edge moment)",
"measure": {"command": "solve.probe", "params": {"step": "Moment", "at": [[250, 0, 1000]]}, "pointer": "/nodes/0/stress/1"},
"value": 60.0,
"tolerance": {"relative": 0.01},
"reason": "the target is the exact thin-shell value (6M/t²) and the stress is uniform over the patch; the 8 × 8 flat facets tilt the element axes by ±1.875° from the hoop direction, which moves 0.1% of it into σ_zz, and 16 × 16 is within 0.03% (see README)"
},
{
"id": "stt_E_pressure",
"quantity": "tangential stress at E, outer surface, load case 2 (pressure and edge traction)",
"measure": {"command": "solve.probe", "params": {"step": "Pressure", "at": [[250, 0, 1000]]}, "pointer": "/nodes/0/stress/1"},
"value": 60.0,
"tolerance": {"relative": 0.01},
"reason": "the target is the exact membrane value (pR/t) and the stress is uniform over the patch; as in load case 1 the facets' tilt and the pressure on flat facets keep 8 × 8 within 0.2%, 16 × 16 within 0.05% (see README)"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"stt_E_moment": {
"band": 6.000000000000001e-8,
"value": 59.93576769689798
},
"stt_E_pressure": {
"band": 6.000000000000001e-8,
"value": 59.90367733652293
}
}
}
+165
View File
@@ -0,0 +1,165 @@
[
{"command": "mesh.node.add", "params": {"label": 1, "xyz": [0.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 2, "xyz": [62.5, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 3, "xyz": [125.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 4, "xyz": [187.5, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 5, "xyz": [250.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 6, "xyz": [312.5, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 7, "xyz": [375.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 8, "xyz": [437.5, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 9, "xyz": [500.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 10, "xyz": [0.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 11, "xyz": [62.5, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 12, "xyz": [125.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 13, "xyz": [187.5, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 14, "xyz": [250.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 15, "xyz": [312.5, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 16, "xyz": [375.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 17, "xyz": [437.5, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 18, "xyz": [500.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 19, "xyz": [0.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 20, "xyz": [62.5, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 21, "xyz": [125.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 22, "xyz": [187.5, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 23, "xyz": [250.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 24, "xyz": [312.5, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 25, "xyz": [375.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 26, "xyz": [437.5, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 27, "xyz": [500.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 28, "xyz": [0.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 29, "xyz": [62.5, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 30, "xyz": [125.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 31, "xyz": [187.5, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 32, "xyz": [250.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 33, "xyz": [312.5, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 34, "xyz": [375.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 35, "xyz": [437.5, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 36, "xyz": [500.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 37, "xyz": [0.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 38, "xyz": [62.5, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 39, "xyz": [125.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 40, "xyz": [187.5, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 41, "xyz": [250.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 42, "xyz": [312.5, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 43, "xyz": [375.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 44, "xyz": [437.5, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 45, "xyz": [500.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 46, "xyz": [0.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 47, "xyz": [62.5, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 48, "xyz": [125.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 49, "xyz": [187.5, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 50, "xyz": [250.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 51, "xyz": [312.5, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 52, "xyz": [375.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 53, "xyz": [437.5, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 54, "xyz": [500.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 55, "xyz": [0.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 56, "xyz": [62.5, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 57, "xyz": [125.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 58, "xyz": [187.5, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 59, "xyz": [250.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 60, "xyz": [312.5, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 61, "xyz": [375.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 62, "xyz": [437.5, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 63, "xyz": [500.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 64, "xyz": [0.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 65, "xyz": [62.5, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 66, "xyz": [125.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 67, "xyz": [187.5, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 68, "xyz": [250.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 69, "xyz": [312.5, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 70, "xyz": [375.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 71, "xyz": [437.5, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 72, "xyz": [500.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 73, "xyz": [0.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 74, "xyz": [62.5, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 75, "xyz": [125.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 76, "xyz": [187.5, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 77, "xyz": [250.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 78, "xyz": [312.5, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 79, "xyz": [375.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 80, "xyz": [437.5, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 81, "xyz": [500.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.element.add", "params": {"label": 1, "kind": "quad4", "nodes": [1, 2, 11, 10]}},
{"command": "mesh.element.add", "params": {"label": 2, "kind": "quad4", "nodes": [2, 3, 12, 11]}},
{"command": "mesh.element.add", "params": {"label": 3, "kind": "quad4", "nodes": [3, 4, 13, 12]}},
{"command": "mesh.element.add", "params": {"label": 4, "kind": "quad4", "nodes": [4, 5, 14, 13]}},
{"command": "mesh.element.add", "params": {"label": 5, "kind": "quad4", "nodes": [5, 6, 15, 14]}},
{"command": "mesh.element.add", "params": {"label": 6, "kind": "quad4", "nodes": [6, 7, 16, 15]}},
{"command": "mesh.element.add", "params": {"label": 7, "kind": "quad4", "nodes": [7, 8, 17, 16]}},
{"command": "mesh.element.add", "params": {"label": 8, "kind": "quad4", "nodes": [8, 9, 18, 17]}},
{"command": "mesh.element.add", "params": {"label": 9, "kind": "quad4", "nodes": [10, 11, 20, 19]}},
{"command": "mesh.element.add", "params": {"label": 10, "kind": "quad4", "nodes": [11, 12, 21, 20]}},
{"command": "mesh.element.add", "params": {"label": 11, "kind": "quad4", "nodes": [12, 13, 22, 21]}},
{"command": "mesh.element.add", "params": {"label": 12, "kind": "quad4", "nodes": [13, 14, 23, 22]}},
{"command": "mesh.element.add", "params": {"label": 13, "kind": "quad4", "nodes": [14, 15, 24, 23]}},
{"command": "mesh.element.add", "params": {"label": 14, "kind": "quad4", "nodes": [15, 16, 25, 24]}},
{"command": "mesh.element.add", "params": {"label": 15, "kind": "quad4", "nodes": [16, 17, 26, 25]}},
{"command": "mesh.element.add", "params": {"label": 16, "kind": "quad4", "nodes": [17, 18, 27, 26]}},
{"command": "mesh.element.add", "params": {"label": 17, "kind": "quad4", "nodes": [19, 20, 29, 28]}},
{"command": "mesh.element.add", "params": {"label": 18, "kind": "quad4", "nodes": [20, 21, 30, 29]}},
{"command": "mesh.element.add", "params": {"label": 19, "kind": "quad4", "nodes": [21, 22, 31, 30]}},
{"command": "mesh.element.add", "params": {"label": 20, "kind": "quad4", "nodes": [22, 23, 32, 31]}},
{"command": "mesh.element.add", "params": {"label": 21, "kind": "quad4", "nodes": [23, 24, 33, 32]}},
{"command": "mesh.element.add", "params": {"label": 22, "kind": "quad4", "nodes": [24, 25, 34, 33]}},
{"command": "mesh.element.add", "params": {"label": 23, "kind": "quad4", "nodes": [25, 26, 35, 34]}},
{"command": "mesh.element.add", "params": {"label": 24, "kind": "quad4", "nodes": [26, 27, 36, 35]}},
{"command": "mesh.element.add", "params": {"label": 25, "kind": "quad4", "nodes": [28, 29, 38, 37]}},
{"command": "mesh.element.add", "params": {"label": 26, "kind": "quad4", "nodes": [29, 30, 39, 38]}},
{"command": "mesh.element.add", "params": {"label": 27, "kind": "quad4", "nodes": [30, 31, 40, 39]}},
{"command": "mesh.element.add", "params": {"label": 28, "kind": "quad4", "nodes": [31, 32, 41, 40]}},
{"command": "mesh.element.add", "params": {"label": 29, "kind": "quad4", "nodes": [32, 33, 42, 41]}},
{"command": "mesh.element.add", "params": {"label": 30, "kind": "quad4", "nodes": [33, 34, 43, 42]}},
{"command": "mesh.element.add", "params": {"label": 31, "kind": "quad4", "nodes": [34, 35, 44, 43]}},
{"command": "mesh.element.add", "params": {"label": 32, "kind": "quad4", "nodes": [35, 36, 45, 44]}},
{"command": "mesh.element.add", "params": {"label": 33, "kind": "quad4", "nodes": [37, 38, 47, 46]}},
{"command": "mesh.element.add", "params": {"label": 34, "kind": "quad4", "nodes": [38, 39, 48, 47]}},
{"command": "mesh.element.add", "params": {"label": 35, "kind": "quad4", "nodes": [39, 40, 49, 48]}},
{"command": "mesh.element.add", "params": {"label": 36, "kind": "quad4", "nodes": [40, 41, 50, 49]}},
{"command": "mesh.element.add", "params": {"label": 37, "kind": "quad4", "nodes": [41, 42, 51, 50]}},
{"command": "mesh.element.add", "params": {"label": 38, "kind": "quad4", "nodes": [42, 43, 52, 51]}},
{"command": "mesh.element.add", "params": {"label": 39, "kind": "quad4", "nodes": [43, 44, 53, 52]}},
{"command": "mesh.element.add", "params": {"label": 40, "kind": "quad4", "nodes": [44, 45, 54, 53]}},
{"command": "mesh.element.add", "params": {"label": 41, "kind": "quad4", "nodes": [46, 47, 56, 55]}},
{"command": "mesh.element.add", "params": {"label": 42, "kind": "quad4", "nodes": [47, 48, 57, 56]}},
{"command": "mesh.element.add", "params": {"label": 43, "kind": "quad4", "nodes": [48, 49, 58, 57]}},
{"command": "mesh.element.add", "params": {"label": 44, "kind": "quad4", "nodes": [49, 50, 59, 58]}},
{"command": "mesh.element.add", "params": {"label": 45, "kind": "quad4", "nodes": [50, 51, 60, 59]}},
{"command": "mesh.element.add", "params": {"label": 46, "kind": "quad4", "nodes": [51, 52, 61, 60]}},
{"command": "mesh.element.add", "params": {"label": 47, "kind": "quad4", "nodes": [52, 53, 62, 61]}},
{"command": "mesh.element.add", "params": {"label": 48, "kind": "quad4", "nodes": [53, 54, 63, 62]}},
{"command": "mesh.element.add", "params": {"label": 49, "kind": "quad4", "nodes": [55, 56, 65, 64]}},
{"command": "mesh.element.add", "params": {"label": 50, "kind": "quad4", "nodes": [56, 57, 66, 65]}},
{"command": "mesh.element.add", "params": {"label": 51, "kind": "quad4", "nodes": [57, 58, 67, 66]}},
{"command": "mesh.element.add", "params": {"label": 52, "kind": "quad4", "nodes": [58, 59, 68, 67]}},
{"command": "mesh.element.add", "params": {"label": 53, "kind": "quad4", "nodes": [59, 60, 69, 68]}},
{"command": "mesh.element.add", "params": {"label": 54, "kind": "quad4", "nodes": [60, 61, 70, 69]}},
{"command": "mesh.element.add", "params": {"label": 55, "kind": "quad4", "nodes": [61, 62, 71, 70]}},
{"command": "mesh.element.add", "params": {"label": 56, "kind": "quad4", "nodes": [62, 63, 72, 71]}},
{"command": "mesh.element.add", "params": {"label": 57, "kind": "quad4", "nodes": [64, 65, 74, 73]}},
{"command": "mesh.element.add", "params": {"label": 58, "kind": "quad4", "nodes": [65, 66, 75, 74]}},
{"command": "mesh.element.add", "params": {"label": 59, "kind": "quad4", "nodes": [66, 67, 76, 75]}},
{"command": "mesh.element.add", "params": {"label": 60, "kind": "quad4", "nodes": [67, 68, 77, 76]}},
{"command": "mesh.element.add", "params": {"label": 61, "kind": "quad4", "nodes": [68, 69, 78, 77]}},
{"command": "mesh.element.add", "params": {"label": 62, "kind": "quad4", "nodes": [69, 70, 79, 78]}},
{"command": "mesh.element.add", "params": {"label": 63, "kind": "quad4", "nodes": [70, 71, 80, 79]}},
{"command": "mesh.element.add", "params": {"label": 64, "kind": "quad4", "nodes": [71, 72, 81, 80]}},
{"command": "model.set.create", "params": {"name": "patch", "elements": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64]}},
{"command": "model.set.create", "params": {"name": "inside", "faces": [{"element": 1, "face": 2}, {"element": 2, "face": 2}, {"element": 3, "face": 2}, {"element": 4, "face": 2}, {"element": 5, "face": 2}, {"element": 6, "face": 2}, {"element": 7, "face": 2}, {"element": 8, "face": 2}, {"element": 9, "face": 2}, {"element": 10, "face": 2}, {"element": 11, "face": 2}, {"element": 12, "face": 2}, {"element": 13, "face": 2}, {"element": 14, "face": 2}, {"element": 15, "face": 2}, {"element": 16, "face": 2}, {"element": 17, "face": 2}, {"element": 18, "face": 2}, {"element": 19, "face": 2}, {"element": 20, "face": 2}, {"element": 21, "face": 2}, {"element": 22, "face": 2}, {"element": 23, "face": 2}, {"element": 24, "face": 2}, {"element": 25, "face": 2}, {"element": 26, "face": 2}, {"element": 27, "face": 2}, {"element": 28, "face": 2}, {"element": 29, "face": 2}, {"element": 30, "face": 2}, {"element": 31, "face": 2}, {"element": 32, "face": 2}, {"element": 33, "face": 2}, {"element": 34, "face": 2}, {"element": 35, "face": 2}, {"element": 36, "face": 2}, {"element": 37, "face": 2}, {"element": 38, "face": 2}, {"element": 39, "face": 2}, {"element": 40, "face": 2}, {"element": 41, "face": 2}, {"element": 42, "face": 2}, {"element": 43, "face": 2}, {"element": 44, "face": 2}, {"element": 45, "face": 2}, {"element": 46, "face": 2}, {"element": 47, "face": 2}, {"element": 48, "face": 2}, {"element": 49, "face": 2}, {"element": 50, "face": 2}, {"element": 51, "face": 2}, {"element": 52, "face": 2}, {"element": 53, "face": 2}, {"element": 54, "face": 2}, {"element": 55, "face": 2}, {"element": 56, "face": 2}, {"element": 57, "face": 2}, {"element": 58, "face": 2}, {"element": 59, "face": 2}, {"element": 60, "face": 2}, {"element": 61, "face": 2}, {"element": 62, "face": 2}, {"element": 63, "face": 2}, {"element": 64, "face": 2}]}},
{"command": "model.set.create", "params": {"name": "AB", "nodes": [1, 2, 3, 4, 5, 6, 7, 8, 9]}},
{"command": "model.set.create", "params": {"name": "AD_BC", "nodes": [1, 9, 10, 18, 19, 27, 28, 36, 37, 45, 46, 54, 55, 63, 64, 72, 73, 81]}},
{"command": "model.set.create", "params": {"name": "DC_ends", "nodes": [73, 81]}},
{"command": "model.set.create", "params": {"name": "DC_inner", "nodes": [74, 75, 76, 77, 78, 79, 80]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "patch", "set": "patch", "material": "Steel", "kind": {"type": "shell", "thickness": 10.0}}},
{"command": "model.boundary_condition.create", "params": {"name": "AB", "type": "displacement", "target": {"set": "AB"}, "values": [0, 0, 0, 0, 0, 0]}},
{"command": "model.boundary_condition.create", "params": {"name": "AD_BC", "type": "symmetry", "target": {"set": "AD_BC"}, "plane": "x"}},
{"command": "model.load.create", "params": {"name": "moment_ends", "type": "moment", "target": {"set": "DC_ends"}, "vector": [-31250.0, 0, 0], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "moment_inner", "type": "moment", "target": {"set": "DC_inner"}, "vector": [-62500.0, 0, 0], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "pressure", "type": "pressure", "set": "inside", "magnitude": 0.6}},
{"command": "model.load.create", "params": {"name": "pull_ends", "type": "force", "target": {"set": "DC_ends"}, "vector": [0.0, 18111.109242920033, -4852.857095672264], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "pull_inner", "type": "force", "target": {"set": "DC_inner"}, "vector": [0.0, 36222.218485840065, -9705.714191344528], "distribution": "per_node"}},
{"command": "model.step.create", "params": {"name": "Moment", "procedure": {"type": "static"}, "boundary_conditions": ["AB", "AD_BC"], "loads": ["moment_ends", "moment_inner"]}},
{"command": "model.step.create", "params": {"name": "Pressure", "procedure": {"type": "static"}, "boundary_conditions": ["AB", "AD_BC"], "loads": ["pressure", "pull_ends", "pull_inner"]}},
{"command": "solve.run", "params": {}, "id": "solve"}
]
+108
View File
@@ -0,0 +1,108 @@
# NAFEMS LE3 hemispherical shell with point loads (MITC4 shells)
Our model of the published problem: a quarter of a closed hemisphere of mid-surface radius 10 m
and thickness 0.04 m, E = 68.25 GPa, ν = 0.3 (m, kN, kPa: E = 6.825 × 10⁷), centred at the origin
with its pole E on +z. A radial force of 2 kN pulls outward at A (10, 0, 0) on the equator and one
of 2 kN pushes inward at C (0, 10, 0). The edge AE lies on the plane y = 0 and CE on x = 0, both
planes of symmetry; u_z = 0 at E holds the shell vertically; the equator is free. The target is
the radial displacement at A, u_x there.
(LE3 has no hole at the pole, unlike the MacNeal–Harder hemisphere of
`pinched-hemisphere`, which has an 18° hole and different loads.)
## The mesh
The surface is doubly curved, so the recipe writes its nodes and elements one by one with
`mesh.node.add` and `mesh.element.add` ([docs/verification.md](../../../docs/verification.md)).
Meridians every 90°/32 of longitude λ and parallels every 90°/32 of latitude β divide the quarter
into cells; node 1 + i + 33 j lies at R (cos β_j cos λ_i, cos β_j sin λ_i, sin β_j), and the pole is
node 1057. Every cell below the top parallel is a quad4 whose four corners lie on two parallels,
symmetric about its mid-meridian, so it is planar (flat shells model a curved surface best with
flat facets); the 32 cells at the pole are tri3 (MITC3) triangles that share the pole node. All
elements run counter-clockwise seen from outside.
A first mesh projected three faces of a cube onto the sphere (no pole, all quad4), but its cells
are warped, and the flat MITC4 element on warped cells was far too stiff (0.147 at 32 × 32 per
face). The longitude–latitude mesh converges as the table shows.
The recipe was generated with this script (`python3 le3.py 32 > recipe.json`):
```python
import json, math, sys
def le3(n):
"""NAFEMS LE3 hemisphere quadrant (m, kN, kPa): R = 10, x ≥ 0, y ≥ 0, z ≥ 0, pole E on +z.
Meridians every 90°/n of longitude λ, parallels every 90°/n of latitude β; the cells are planar
trapezoids and the ring at the pole is triangles."""
R = 10.0
label = lambda i, j: 1 + i + (n + 1) * j # longitude i, latitude j < n
pole = 1 + (n + 1) * n
commands = []
for j in range(n):
for i in range(n + 1):
lam, beta = math.pi / 2 * i / n, math.pi / 2 * j / n
xyz = [R * math.cos(beta) * math.cos(lam), R * math.cos(beta) * math.sin(lam), R * math.sin(beta)]
commands.append({"command": "mesh.node.add", "params": {"label": label(i, j), "xyz": xyz}})
commands.append({"command": "mesh.node.add", "params": {"label": pole, "xyz": [0.0, 0.0, R]}})
k = 0
for j in range(n):
for i in range(n):
k += 1
if j < n - 1:
kind, nodes = "quad4", [label(i, j), label(i + 1, j), label(i + 1, j + 1), label(i, j + 1)]
else:
kind, nodes = "tri3", [label(i, j), label(i + 1, j), pole]
commands.append({"command": "mesh.element.add", "params": {"label": k, "kind": kind, "nodes": nodes}})
A, C = label(0, 0), label(n, 0)
return commands + [
{"command": "model.set.create", "params": {"name": "shell", "elements": list(range(1, k + 1))}},
{"command": "model.set.create", "params": {"name": "AE", "nodes": [label(0, j) for j in range(n)] + [pole]}},
{"command": "model.set.create", "params": {"name": "CE", "nodes": [label(n, j) for j in range(n)] + [pole]}},
{"command": "model.set.create", "params": {"name": "A", "nodes": [A]}},
{"command": "model.set.create", "params": {"name": "C", "nodes": [C]}},
{"command": "model.set.create", "params": {"name": "E", "nodes": [pole]}},
{"command": "model.material.create", "params": {"name": "Alloy", "elastic": {"type": "isotropic", "young": 6.825e7, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "shell", "set": "shell", "material": "Alloy", "kind": {"type": "shell", "thickness": 0.04}}},
{"command": "model.boundary_condition.create", "params": {"name": "AE", "type": "symmetry", "target": {"set": "AE"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "CE", "type": "symmetry", "target": {"set": "CE"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "E", "type": "displacement", "target": {"set": "E"}, "values": [None, None, 0, None, None, None]}},
{"command": "model.load.create", "params": {"name": "pull_A", "type": "force", "target": {"set": "A"}, "vector": [2.0, 0, 0]}},
{"command": "model.load.create", "params": {"name": "push_C", "type": "force", "target": {"set": "C"}, "vector": [0, -2.0, 0]}},
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["AE", "CE", "E"], "loads": ["pull_A", "push_C"]}},
{"command": "solve.static", "params": {}, "id": "solve"},
]
n = int(sys.argv[1]) if len(sys.argv) > 1 else 32
print("[\n " + ",\n ".join(json.dumps(c) for c in le3(n)) + "\n]")
```
## Refinement
Every row is the listing above with the row's divisions, solved and probed at A as the case does;
the case mesh is 32 × 32:
| cells | u_x at A (m) | error |
|---|---|---|
| 8 × 8 | 0.18197 | −1.64% |
| 16 × 16 | 0.18324 | −0.95% |
| 32 × 32 (this case) | 0.18418 | −0.44% |
| 64 × 64 | 0.18469 | −0.17% |
The result rises with every refinement and its increments shrink, but slowly: under a point load,
a shell with transverse shear flexibility has no finite limit, only a very slow (logarithmic)
growth (the ESRD guide makes the same remark for its own p-version solution, 0.1844). The next
refinement moves the case value by 0.28%, a seventh of the tolerance.
## Tolerance
2%: the target is published to three figures and the displacement under a point load is not a
converging quantity in the strict sense (above); 32 × 32 is within 0.44%. `fixture.json` holds the
32 × 32 value: the mesh is written by the recipe, so it is bit-identical on every platform.
## Running it
```sh
cargo xtask oracle --case nafems-le3
cargo xtask oracle --bless nafems-le3 # only after the case passes
```
+15
View File
@@ -0,0 +1,15 @@
{
"title": "NAFEMS LE3 hemispherical shell with point loads (MITC4 shells)",
"kind": "benchmark",
"source": "NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE3 (target: radial displacement 0.185 m at A); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.3 'LE3: Hemispherical shell with point loads', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 12",
"quantities": [
{
"id": "ur_A",
"quantity": "radial displacement at A (10, 0, 0), m",
"measure": {"command": "solve.probe", "params": {"step": "Load", "at": [[10, 0, 0]]}, "pointer": "/nodes/0/displacement/0"},
"value": 0.185,
"tolerance": {"relative": 0.02},
"reason": "the target is published to three figures; the displacement under a point load on a shear-deformable shell keeps growing slowly as the mesh is refined (32 × 32 is 0.44% low, 64 × 64 0.16%; see README)"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"ur_A": {
"band": 1.85e-10,
"value": 0.18417526380456614
}
}
}
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
# NAFEMS LE5 Z-section cantilever (hex20)
Our model of the published problem: a cantilever 10 m long (x) whose cross-section is a Z of
0.1 m thick walls, E = 210 GPa, ν = 0.3 (m, MN, MPa). On the mid-surface the web is 2 m wide, in
the plane z = 0 from y = −1 to y = 1, and the two flanges are 1 m high: the upper one at y = 1
rises to z = 1, the lower one at y = −1 drops to z = −1. Every displacement is zero at x = 0. A
torque of 1.2 MN·m acts at x = 10 as two shear forces of 0.6 MN, one along each flange's end
(+z on the upper flange, −z on the lower), 2 m apart. The target is the axial stress σ_xx at A,
x = 2.5 on the mid-surface of the lower flange at its free edge (2.5, −1, −1), where the restrained
warping of the section puts its largest axial stress.
## The mesh
The walls are solid hex20 blocks (`mesh.block`), the route LE1 and LE10 take: the issue allows
quad4 shells or hex20, and the shell model reaches a different limit (below). Five blocks,
2 elements through every wall: the two flanges (0.95 m outside the web, k = 4 elements), the two
corner blocks where a flange meets the web (2 × 2), and the web between them (8 elements); 32
elements along the length. The blocks share their faces, so `mesh.block` joins them. The shear
force on each flange is a total over the flange block's end face (z from ±0.05 to ±1), split by
area. A is a node: the mid-plane of the lower flange (y = −1) is a node plane of its 2 elements
through the thickness.
The recipe was generated with this script (`python3 le5.py 4 > recipe.json`):
```python
import json, sys
def le5_solid(k):
"""NAFEMS LE5 Z-section as hex20 solid blocks (m, MN, MPa): mid-surface web 2 wide in z = 0,
flanges at y = ±1 reaching z = ±1, all walls 0.1 thick, length 10 along x; k sets the density
(8k cells along the length, k per 0.95 m across the walls, 2 through the thickness)."""
h = 0.05
blocks = { # name: (y0, y1, z0, z1, ny, nz)
"lower_flange": (-1 - h, -1 + h, -1.0, -h, 2, k),
"lower_corner": (-1 - h, -1 + h, -h, h, 2, 2),
"web": (-1 + h, 1 - h, -h, h, 2 * k, 2),
"upper_corner": (1 - h, 1 + h, -h, h, 2, 2),
"upper_flange": (1 - h, 1 + h, h, 1.0, 2, k),
}
commands = []
for name, (y0, y1, z0, z1, ny, nz) in blocks.items():
base = [[0.0, y0, z0], [0.0, y1, z0], [0.0, y1, z1], [0.0, y0, z1]]
corners = base + [[10.0, y, z] for _, y, z in base]
commands.append({"command": "mesh.block", "params": {"body": name, "kind": "hex20", "corners": corners, "divisions": [ny, nz, 8 * k], "faces": {"1": "root", "2": "tip"}}})
return commands + [
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": b} for b in blocks]}},
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": b, "face": "root"} for b in blocks]}},
{"command": "model.set.create", "params": {"name": "lower_tip", "geometry": [{"body": "lower_flange", "face": "tip"}]}},
{"command": "model.set.create", "params": {"name": "upper_tip", "geometry": [{"body": "upper_flange", "face": "tip"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "z_section", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "root", "type": "fixed", "target": {"set": "root"}}},
# the 1.2 MN·m torque: 0.6 MN along each flange's end face, 2 m apart
{"command": "model.load.create", "params": {"name": "lower_shear", "type": "force", "target": {"set": "lower_tip"}, "vector": [0, 0, -0.6]}},
{"command": "model.load.create", "params": {"name": "upper_shear", "type": "force", "target": {"set": "upper_tip"}, "vector": [0, 0, 0.6]}},
{"command": "model.step.create", "params": {"name": "Torque", "procedure": {"type": "static"}, "boundary_conditions": ["root"], "loads": ["lower_shear", "upper_shear"]}},
{"command": "solve.static", "params": {}, "id": "solve"},
]
k = int(sys.argv[1]) if len(sys.argv) > 1 else 4
print("[\n " + ",\n ".join(json.dumps(c) for c in le5_solid(k)) + "\n]")
```
## Refinement
Every row is the listing above with the row's k, solved and probed at A as the case does; the
case mesh is k = 4:
| k | elements | σ_xx at A | error |
|---|---|---|---|
| 1 | 128 | −108.93 | +0.86% |
| 2 | 384 | −109.27 | +1.17% |
| 4 (this case) | 1280 | −109.46 | +1.35% |
| 8 | 4608 | −109.60 | +1.48% |
The result converges to about −109.7, 1.6% beyond the target. The published value comes from a
thin-walled idealisation of the section; a solid model of the same section differs from it by
about that much (ESRD's guide reports −109.5 for its converged p-version solid model on two
meshes, p. 16). The next refinement of the case mesh moves the value by 0.13%, under a tenth of
the tolerance.
A model of quad4 MITC4 shells on the mid-surface (walls meeting at the junction lines, the edge
shears as consistent nodal forces) converges further away, to about −111.3 (320 elements along the
length and 16 per metre across the walls: −111.65 and −111.06 at A from the two elements that meet
there), 3.1% beyond the target (the Abaqus Benchmarks Guide, Table 4.2.5–1, reports −110.38 for its
S4 shell on the refined mesh); we have not separated how much of the difference is the shell
idealisation of the junctions. That is why this case is the solid model.
## Tolerance
2%: the target is published to three figures, and the solid model's converged value differs from
the thin-walled reference by about 1.5% (above). `fixture.json` holds the k = 4 value: the mesh is
built by `mesh.block` from the recipe's corners, so it is the same on every platform.
## Running it
```sh
cargo xtask oracle --case nafems-le5
cargo xtask oracle --bless nafems-le5 # only after the case passes
```
+15
View File
@@ -0,0 +1,15 @@
{
"title": "NAFEMS LE5 Z-section cantilever (hex20)",
"kind": "benchmark",
"source": "NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE5 (target: axial stress −108 MPa at A, mid-surface); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.5 'LE5: Z-section cantilever', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 15",
"quantities": [
{
"id": "sxx_A",
"quantity": "σ_xx at A (2.5, −1, −1), mid-surface of the lower flange at its free edge",
"measure": {"command": "solve.probe", "params": {"step": "Torque", "at": [[2.5, -1, -1]]}, "pointer": "/nodes/0/stress/0"},
"value": -108.0,
"tolerance": {"relative": 0.02},
"reason": "the target is published to three figures and comes from a thin-walled idealisation; a 3D solid model of the section converges about 1.5% beyond it (ours: −109.46 here, −109.60 on the next mesh; ESRD's p-version solid: −109.5, see README)"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"sxx_A": {
"band": 1.0945472240551884e-7,
"value": -109.45472240551884
}
}
}
+18
View File
@@ -0,0 +1,18 @@
[
{"command": "mesh.block", "params": {"body": "lower_flange", "kind": "hex20", "corners": [[0.0, -1.05, -1.0], [0.0, -0.95, -1.0], [0.0, -0.95, -0.05], [0.0, -1.05, -0.05], [10.0, -1.05, -1.0], [10.0, -0.95, -1.0], [10.0, -0.95, -0.05], [10.0, -1.05, -0.05]], "divisions": [2, 4, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "mesh.block", "params": {"body": "lower_corner", "kind": "hex20", "corners": [[0.0, -1.05, -0.05], [0.0, -0.95, -0.05], [0.0, -0.95, 0.05], [0.0, -1.05, 0.05], [10.0, -1.05, -0.05], [10.0, -0.95, -0.05], [10.0, -0.95, 0.05], [10.0, -1.05, 0.05]], "divisions": [2, 2, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "mesh.block", "params": {"body": "web", "kind": "hex20", "corners": [[0.0, -0.95, -0.05], [0.0, 0.95, -0.05], [0.0, 0.95, 0.05], [0.0, -0.95, 0.05], [10.0, -0.95, -0.05], [10.0, 0.95, -0.05], [10.0, 0.95, 0.05], [10.0, -0.95, 0.05]], "divisions": [8, 2, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "mesh.block", "params": {"body": "upper_corner", "kind": "hex20", "corners": [[0.0, 0.95, -0.05], [0.0, 1.05, -0.05], [0.0, 1.05, 0.05], [0.0, 0.95, 0.05], [10.0, 0.95, -0.05], [10.0, 1.05, -0.05], [10.0, 1.05, 0.05], [10.0, 0.95, 0.05]], "divisions": [2, 2, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "mesh.block", "params": {"body": "upper_flange", "kind": "hex20", "corners": [[0.0, 0.95, 0.05], [0.0, 1.05, 0.05], [0.0, 1.05, 1.0], [0.0, 0.95, 1.0], [10.0, 0.95, 0.05], [10.0, 1.05, 0.05], [10.0, 1.05, 1.0], [10.0, 0.95, 1.0]], "divisions": [2, 4, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "lower_flange"}, {"body": "lower_corner"}, {"body": "web"}, {"body": "upper_corner"}, {"body": "upper_flange"}]}},
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": "lower_flange", "face": "root"}, {"body": "lower_corner", "face": "root"}, {"body": "web", "face": "root"}, {"body": "upper_corner", "face": "root"}, {"body": "upper_flange", "face": "root"}]}},
{"command": "model.set.create", "params": {"name": "lower_tip", "geometry": [{"body": "lower_flange", "face": "tip"}]}},
{"command": "model.set.create", "params": {"name": "upper_tip", "geometry": [{"body": "upper_flange", "face": "tip"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "z_section", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "root", "type": "fixed", "target": {"set": "root"}}},
{"command": "model.load.create", "params": {"name": "lower_shear", "type": "force", "target": {"set": "lower_tip"}, "vector": [0, 0, -0.6]}},
{"command": "model.load.create", "params": {"name": "upper_shear", "type": "force", "target": {"set": "upper_tip"}, "vector": [0, 0, 0.6]}},
{"command": "model.step.create", "params": {"name": "Torque", "procedure": {"type": "static"}, "boundary_conditions": ["root"], "loads": ["lower_shear", "upper_shear"]}},
{"command": "solve.static", "params": {}, "id": "solve"}
]
+94
View File
@@ -0,0 +1,94 @@
# NAFEMS LE7 axisymmetric cylinder/sphere under pressure (axisymmetric quad8)
Our model of the published problem: a thin-walled vessel, a cylinder closed by a hemispherical
cap, of mid-surface radius 1 m and wall thickness 0.025 m, E = 210 GPa, ν = 0.3 (m, MN, MPa),
under an internal pressure of 1 MPa. In FieldCraft's axisymmetric models x is the radius and y the
axis. The cylinder runs from F (y = 0) to the junction C (y = 1.5), the cap from C to the pole A
(y = 2.5). The axial displacement is zero at F (the vessel's plane of symmetry: the whole bottom
edge) and the radial one at A (the pole edge, on the axis). The target is the axial stress σ_yy
on the outer surface at D (R = 1.0125, y = 1.4034), 0.0966 m below the junction, inside the
bending boundary layer the cap's different stiffness causes.
## The mesh
Four `mesh.block` quad8 patches through the wall thickness: three cylinder blocks with E
(y = 1.1136) and D as block corners, so D is a node, and the cap, a quarter ring whose two curved
edges are arcs about (0, 1.5) and whose fourth edge lies on the axis. 2 elements through the
wall, about 50 per metre along it (56, 14 and 5 in the cylinder blocks, 79 round the cap); the
blocks share their edges, so `mesh.block` joins them. The pressure acts on the inner edges of all
four blocks.
The recipe was generated with this script (`python3 le7.py 2 50 > recipe.json`):
```python
import json, math, sys
def le7(nt, per_m):
"""NAFEMS LE7 cylinder with a hemispherical cap, axisymmetric (m, MN, MPa): x is the radius,
y the axis. Mid-surface radius 1, wall 0.025 thick; the cylinder runs from F (y = 0, the plane
of symmetry) to C (y = 1.5), the cap from C to the pole A (y = 2.5). nt elements through the
wall, about per_m elements per metre along it."""
ri, ro = 1 - 0.0125, 1 + 0.0125
cuts = [0.0, 1.1136, 1.4034, 1.5] # F, E, D, C: E and D are nodes
along = lambda length: max(1, round(length * per_m))
commands = []
for k, (y0, y1) in enumerate(zip(cuts, cuts[1:])):
commands.append({"command": "mesh.block", "params": {"body": f"cylinder_{k + 1}", "kind": "quad8",
"corners": [[ri, y0, 0], [ro, y0, 0], [ro, y1, 0], [ri, y1, 0]], "divisions": [nt, along(y1 - y0)],
"edges": [{"corners": [1, 4], "name": "inside"}, {"corners": [1, 2], "name": "bottom"}]}})
commands.append({"command": "mesh.block", "params": {"body": "cap", "kind": "quad8",
"corners": [[ri, 1.5, 0], [ro, 1.5, 0], [0, 1.5 + ro, 0], [0, 1.5 + ri, 0]], "divisions": [nt, along(math.pi / 2)],
"edges": [{"corners": [2, 3], "arc": {"center": [0, 1.5, 0]}}, {"corners": [4, 1], "arc": {"center": [0, 1.5, 0]}, "name": "inside"},
{"corners": [3, 4], "name": "pole"}]}})
bodies = ["cylinder_1", "cylinder_2", "cylinder_3", "cap"]
return commands + [
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": b, "face": "area"} for b in bodies]}},
{"command": "model.set.create", "params": {"name": "inside", "geometry": [{"body": b, "edge": "inside"} for b in bodies]}},
{"command": "model.set.create", "params": {"name": "F", "geometry": [{"body": "cylinder_1", "edge": "bottom"}]}},
{"command": "model.set.create", "params": {"name": "A", "geometry": [{"body": "cap", "edge": "pole"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "vessel", "set": "all", "material": "Steel", "kind": {"type": "axisymmetric"}}},
{"command": "model.boundary_condition.create", "params": {"name": "F", "type": "symmetry", "target": {"set": "F"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "A", "type": "symmetry", "target": {"set": "A"}, "plane": "x"}},
{"command": "model.load.create", "params": {"name": "pressure", "type": "pressure", "set": "inside", "magnitude": 1.0}},
{"command": "model.step.create", "params": {"name": "Pressure", "procedure": {"type": "static"}, "boundary_conditions": ["F", "A"], "loads": ["pressure"]}},
{"command": "solve.static", "params": {}, "id": "solve"},
]
nt, per_m = (int(a) for a in sys.argv[1:3]) if len(sys.argv) > 2 else (2, 50)
print("[\n " + ",\n ".join(json.dumps(c) for c in le7(nt, per_m)) + "\n]")
```
## Refinement
Every row is the listing above with the row's divisions, solved and probed at D as the case does;
the case mesh is 2 × 50:
| through the wall × per metre | σ_yy at D | against 25.86 | against 25.27 |
|---|---|---|---|
| 2 × 25 | 25.331 | −2.05% | +0.24% |
| 2 × 50 (this case) | 25.283 | −2.23% | +0.05% |
| 4 × 100 | 25.271 | −2.28% | +0.00% |
| 8 × 200 | 25.269 | −2.29% | −0.00% |
Far from the junction the model is exact: at F the axial stress on the outer surface is 19.503
(p r_i²/(r_o² − r_i²)) and the hoop stress 39.006 (Lamé).
## Two references, one known failure
The published target, 25.86, comes from axisymmetric thin-shell theory. The ESRD guide solves the
same problem with axisymmetric solid elements and reports 25.27 on two meshes, 2.3% below the
target, and our solid model converges to the same 25.27: the difference is the model, not the
mesh. So the case keeps the NAFEMS target with the
tolerance a shell model would earn, 1%, marked `known_failure` until FieldCraft has an
axisymmetric shell element, and checks the solid model against the solid reference, also at 1%.
`fixture.json` holds the passing quantity's 2 × 50 value: the mesh is built by `mesh.block` from
the recipe's corners, so it is the same on every platform.
## Running it
```sh
cargo xtask oracle --case nafems-le7
cargo xtask oracle --bless nafems-le7 # only after the case passes
```
+24
View File
@@ -0,0 +1,24 @@
{
"title": "NAFEMS LE7 axisymmetric cylinder/sphere under pressure (axisymmetric quad8)",
"kind": "benchmark",
"source": "NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE7 (target: axial stress 25.86 MPa on the outer surface at D, R = 1.0125, Z = 1.4034), as reproduced in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 23, which also gives its converged axisymmetric solid result, 25.27 MPa (p. 24); the Abaqus 2016 Benchmarks Guide, section 4.2.7 'LE7: Axisymmetric cylinder/sphere under pressure', gives the target rounded to 25.9 MPa",
"quantities": [
{
"id": "syy_D",
"quantity": "axial stress σ_yy at D (1.0125, 1.4034), outer surface",
"measure": {"command": "solve.probe", "params": {"step": "Pressure", "at": [[1.0125, 1.4034, 0]]}, "pointer": "/nodes/0/stress/1"},
"value": 25.86,
"tolerance": {"relative": 0.01},
"reason": "the target is published to four figures for the thin-shell model of the vessel, and a model of the same theory converges well within 1%",
"known_failure": "the target is the axisymmetric thin-shell solution and FieldCraft has no axisymmetric shell element; our axisymmetric solid model converges to 25.27 (−2.3%), the value ESRD reports for its converged axisymmetric solid (quantity syy_D_solid)"
},
{
"id": "syy_D_solid",
"quantity": "axial stress σ_yy at D (1.0125, 1.4034), outer surface, against the converged solid model",
"measure": {"command": "solve.probe", "params": {"step": "Pressure", "at": [[1.0125, 1.4034, 0]]}, "pointer": "/nodes/0/stress/1"},
"value": 25.27,
"tolerance": {"relative": 0.01},
"reason": "the same model idealisation (axisymmetric solid) as the reference, which ESRD reports converged to under 1% on two meshes; ours moves by 0.05% on the next refinement (see README)"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"syy_D_solid": {
"band": 2.5282545696283336e-8,
"value": 25.282545696283336
}
}
}
+17
View File
@@ -0,0 +1,17 @@
[
{"command": "mesh.block", "params": {"body": "cylinder_1", "kind": "quad8", "corners": [[0.9875, 0.0, 0], [1.0125, 0.0, 0], [1.0125, 1.1136, 0], [0.9875, 1.1136, 0]], "divisions": [2, 56], "edges": [{"corners": [1, 4], "name": "inside"}, {"corners": [1, 2], "name": "bottom"}]}},
{"command": "mesh.block", "params": {"body": "cylinder_2", "kind": "quad8", "corners": [[0.9875, 1.1136, 0], [1.0125, 1.1136, 0], [1.0125, 1.4034, 0], [0.9875, 1.4034, 0]], "divisions": [2, 14], "edges": [{"corners": [1, 4], "name": "inside"}, {"corners": [1, 2], "name": "bottom"}]}},
{"command": "mesh.block", "params": {"body": "cylinder_3", "kind": "quad8", "corners": [[0.9875, 1.4034, 0], [1.0125, 1.4034, 0], [1.0125, 1.5, 0], [0.9875, 1.5, 0]], "divisions": [2, 5], "edges": [{"corners": [1, 4], "name": "inside"}, {"corners": [1, 2], "name": "bottom"}]}},
{"command": "mesh.block", "params": {"body": "cap", "kind": "quad8", "corners": [[0.9875, 1.5, 0], [1.0125, 1.5, 0], [0, 2.5125, 0], [0, 2.4875, 0]], "divisions": [2, 79], "edges": [{"corners": [2, 3], "arc": {"center": [0, 1.5, 0]}}, {"corners": [4, 1], "arc": {"center": [0, 1.5, 0]}, "name": "inside"}, {"corners": [3, 4], "name": "pole"}]}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "cylinder_1", "face": "area"}, {"body": "cylinder_2", "face": "area"}, {"body": "cylinder_3", "face": "area"}, {"body": "cap", "face": "area"}]}},
{"command": "model.set.create", "params": {"name": "inside", "geometry": [{"body": "cylinder_1", "edge": "inside"}, {"body": "cylinder_2", "edge": "inside"}, {"body": "cylinder_3", "edge": "inside"}, {"body": "cap", "edge": "inside"}]}},
{"command": "model.set.create", "params": {"name": "F", "geometry": [{"body": "cylinder_1", "edge": "bottom"}]}},
{"command": "model.set.create", "params": {"name": "A", "geometry": [{"body": "cap", "edge": "pole"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "vessel", "set": "all", "material": "Steel", "kind": {"type": "axisymmetric"}}},
{"command": "model.boundary_condition.create", "params": {"name": "F", "type": "symmetry", "target": {"set": "F"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "A", "type": "symmetry", "target": {"set": "A"}, "plane": "x"}},
{"command": "model.load.create", "params": {"name": "pressure", "type": "pressure", "set": "inside", "magnitude": 1.0}},
{"command": "model.step.create", "params": {"name": "Pressure", "procedure": {"type": "static"}, "boundary_conditions": ["F", "A"], "loads": ["pressure"]}},
{"command": "solve.static", "params": {}, "id": "solve"}
]
+39
View File
@@ -0,0 +1,39 @@
# Flow-past clip
`make.py` regenerates the gallery clip and still of the `flow-past` sample (docs/examples.md):
`docs/media/examples/flow-past-transient.mp4` (the start-up transient of air flowing past the
L-bracket, from the uniform 25 mm/s initial field to the settled wake) and
`docs/media/examples/flow-past-flow.png` (a clean iso render of the settled flow).
```sh
python3 tools/flow-clip/make.py # build, run, encode into docs/media/examples
python3 tools/flow-clip/make.py --no-build # with the release binaries already built
python3 tools/flow-clip/make.py --frames /tmp/f # also keep the rendered pictures
python3 tools/flow-clip/make.py --gif # also write a GIF (not committed; larger)
```
Needs Python 3 (standard library only), a display for the FieldCraft window (the pictures are
drawn offscreen with `ui.render`, so a locked or virtual display is fine — on a headless machine
run it under `xvfb-run`), and `ffmpeg` with libx264 on the `PATH` (or `FFMPEG`). It honours
`CARGO_TARGET_DIR`; set `FIELDCRAFT_GPU=software` for a software renderer.
What it does:
1. Builds `fieldcraft` and `fieldcraft-cli` (release) and starts the app with `--control PORT`.
2. Opens the `flow-past` sample and, so the transient plays smoothly, edits its fluid step to
store `FLOW_CLIP_FRAMES` frames (48 by default) over the 16 s run (`model.step.edit`), then
solves the lattice Boltzmann step on the CPU (`solve.fluid`).
3. Fixes the velocity legend clamp (0 to 45 mm/s) so the colours mean the same thing from frame
to frame, sets a light theme and an iso view, and renders the model-only viewport at each
field frame (`results.frame` then `ui.render`): velocity on the mid-slice, the bracket
coloured by the flow beside it, and streamlines seeded upstream, coloured by speed.
4. Captions each frame with the elapsed flow time in a band below, drawn by the demo-video tool's
standard-library TrueType caption drawer (`tools/demo-video/caption.py`, Inter SemiBold).
5. Encodes the frames at 12 fps (the settled wake held at the end) into the MP4 (H.264) and writes
the settled still. `--gif` also writes a GIF (800 px, its own palette); it is not committed
because it is several times larger than the MP4.
The clip shows the wake forming behind the upright and the drag settling; it does not shed, which
needs a finer grid or a collision operator stable near τ⁺ = 0.5 (see issue #557). The geometry is
the original upright-bracket SolidCraft recipe (the `flow-past` sample); nothing comes from a
commercial product.
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Regenerate the flow-past gallery clip and still image: the start-up transient of air flowing
past the L-bracket, from the uniform 25 mm/s initial field to the settled wake.
The FieldCraft desktop app opens the `flow-past` sample, solves the lattice Boltzmann step with a
dense frame output, and draws the flow (velocity on a mid-slice, the bracket coloured by the flow
beside it, streamlines seeded upstream). Each frame of the step is rendered through the control
channel (`ui.render`, the model only, no window furniture), with a fixed legend clamp so the
colours mean the same thing from frame to frame. ffmpeg encodes the frames as `docs/media/examples/
flow-past-transient.mp4`, captioned with the elapsed flow time, and a clean iso render of the
settled flow is written as `docs/media/examples/flow-past-flow.png`. `--gif` also writes a GIF (not
committed — it is several times larger than the MP4; use it for hosting the clip elsewhere).
Python standard library only; ffmpeg encodes the frames. caption.py (the demo-video tool's
standard-library TrueType caption drawer) draws the time bars.
"""
import argparse
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools" / "demo-video"))
import caption # noqa: E402 (the demo-video caption drawer, standard library only)
# The flow-past sample: air at 25 mm/s past the upright L-bracket (Reynolds number 83 on the
# 50 mm upright). The sample solves 16 s (about 1.7 passes through the 240 mm box at 25 mm/s); the
# clip plays that start-up transient. FRAMES field frames are stored (the sample's 10 is too coarse
# for a smooth clip), so the clip holds FRAMES + 1 pictures from the uniform initial field (frame 0,
# 25 mm/s everywhere) to the settled flow.
SAMPLE = "flow-past"
STEP = "Flow"
DOMAIN = "Wind"
END_TIME = float(os.environ.get("FLOW_CLIP_END_TIME", 16.0))
FRAMES = int(os.environ.get("FLOW_CLIP_FRAMES", 48))
# The render: a clean iso view of the domain, each frame the model only (no window chrome). The
# legend clamp is fixed over the whole run so the wake's colours are comparable frame to frame;
# 45 mm/s sits just above the settled peak (about 42 mm/s), 0 for the still fluid in the wake.
WIDTH, HEIGHT = 1280, 720
CLAMP = (0.0, 45.0)
CAMERA = {"yaw": -40.0, "pitch": 24.0, "target": {"center_of": "bbox"}, "fit": True}
# The video: frames per second, and how long the settled flow is held at the end.
FPS = 12
HOLD_END = 18 # frames (1.5 s) on the settled wake
# The caption bar, a band under each picture (so it never hides the flow).
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
CAPTION_HEIGHT = 44
def log(msg):
print(f"flow-clip: {msg}", file=sys.stderr, flush=True)
def target_dir():
return Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target"))
def build():
cargo = os.environ.get("CARGO", "cargo")
subprocess.run([cargo, "build", "--release", "-p", "fieldcraft", "-p", "fieldcraft-cli"], cwd=ROOT, check=True)
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Control:
"""A client of the FieldCraft control channel (one JSON line per request and reply)."""
def __init__(self, port, timeout=180.0):
end = time.monotonic() + timeout
while True:
try:
self.sock = socket.create_connection(("127.0.0.1", port), timeout=5.0)
break
except OSError:
if time.monotonic() > end:
raise SystemExit(f"the control channel on port {port} did not open within {timeout:.0f} s")
time.sleep(0.5)
self.sock.settimeout(600.0)
self.file = self.sock.makefile("rwb")
self.next_id = 0
def send(self, method, params):
self.next_id += 1
self.file.write((json.dumps({"id": self.next_id, "method": method, "params": params}) + "\n").encode())
self.file.flush()
reply = json.loads(self.file.readline())
if not reply.get("ok"):
raise SystemExit(f"{method} {params}: {reply.get('error')}")
return reply.get("result")
def execute(self, command, params=None):
return self.send("engine.execute", {"command": command, "params": params or {}})
def close(self):
try:
self.send("app.quit", {})
except (OSError, SystemExit):
pass
self.file.close()
self.sock.close()
def solve(ctl):
"""Open the sample, make the step store FRAMES frames, and solve the flow."""
ctl.execute("file.sample", {"name": SAMPLE})
# The sample's step stores 10 frames; the clip wants a smoother transient.
ctl.execute("model.step.edit", {"name": STEP, "procedure": {"type": "fluid", "domain": DOMAIN, "end_time": END_TIME, "frames": FRAMES}})
log("solving the flow (lattice Boltzmann) …")
report = ctl.execute("solve.fluid", {"backend": "cpu"})
forces = report.get("forces") or report.get("faces")
log(f"solved: {json.dumps(report.get('stats', report.get('summary', {})))[:200]}")
return forces
def render_frames(ctl, frames_dir):
"""Render the model-only viewport at each field frame, with a fixed legend clamp and the iso
view, and draw its time caption. Return the list of (picture, caption) pairs."""
# The flow display the sample opens with: velocity on the mid-slice, the obstacle coloured and
# streamlines upstream. Fix the legend clamp so the colours are comparable across frames.
# A light background and shaded part, to match the gallery's still pictures.
ctl.send("ui.set", {"dark": False, "style": "shaded_edges", "show_tree": False})
ctl.execute("results.contour", {"field": "velocity", "range": {"min": CLAMP[0], "max": CLAMP[1]}})
ctl.execute("results.surface", {"show": True})
ctl.execute("results.streamlines", {"show": True})
ctl.send("ui.camera", CAMERA)
font = caption.Font.load(CAPTION_FONT)
shots = []
for k in range(FRAMES + 1):
ctl.execute("results.frame", {"frame": k})
shot = frames_dir / f"frame{k:04d}.png"
ctl.send("ui.render", {"path": str(shot), "width": WIDTH, "height": HEIGHT})
t = END_TIME * k / FRAMES
bar = frames_dir / f"cap{k:04d}.png"
bar.write_bytes(caption.caption_bar(font, (WIDTH, CAPTION_HEIGHT), "flow past a bracket", f"t = {t:4.1f} s · Re 83"))
shots.append((shot, bar))
return shots
def sequence(shots, folder):
"""Lay the clip out as numbered frames and captions (the last picture held), returning the two
ffmpeg input patterns and the frame count."""
shutil.rmtree(folder, ignore_errors=True)
(folder / "caps").mkdir(parents=True)
n = 0
held = [(shot, bar, 1) for shot, bar in shots]
held[-1] = (held[-1][0], held[-1][1], HOLD_END)
for shot, bar, count in held:
for _ in range(count):
os.link(shot, folder / f"{n:05d}.png")
os.link(bar, folder / "caps" / f"{n:05d}.png")
n += 1
return str(folder / "%05d.png"), str(folder / "caps" / "%05d.png"), n
def encode(shots, frames_dir, mp4, gif=None):
"""Encode the frames into the MP4 (committed to the repo) and, when `gif` is given, a GIF too
(not committed — it is several times larger; use it for hosting the clip elsewhere)."""
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
pattern, caps, count = sequence(shots, frames_dir / "sequence")
log(f"encoding {count} frames ({count / FPS:.1f} s) into {mp4.name}" + (f" and {gif.name}" if gif else ""))
src = ["-framerate", str(FPS), "-i", pattern, "-framerate", str(FPS), "-i", caps]
# Each picture padded with a band below, the caption bar laid over the band.
captioned = f"[0:v]pad={WIDTH}:{HEIGHT + CAPTION_HEIGHT}:0:0[w];[w][1:v]overlay=0:{HEIGHT}"
common = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
mp4.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[*common, *src, "-filter_complex", captioned, "-c:v", "libx264", "-preset", "veryslow", "-crf", "26",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)],
check=True,
)
if gif:
subprocess.run(
[*common, *src, "-filter_complex",
f"{captioned},scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];"
"[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", str(gif)],
check=True,
)
def still(ctl, path):
"""A clean iso render of the settled flow for the gallery still."""
ctl.execute("results.frame", {"frame": FRAMES})
ctl.send("ui.render", {"path": str(path), "width": WIDTH, "height": HEIGHT})
log(f"wrote the still {path}")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", type=Path, default=ROOT / "docs" / "media" / "examples", help="folder for the clip and still")
ap.add_argument("--frames", type=Path, help="keep the rendered pictures in this folder")
ap.add_argument("--gif", action="store_true", help="also write the GIF (not committed; for hosting the clip elsewhere)")
ap.add_argument("--no-build", action="store_true", help="use the binaries already built")
args = ap.parse_args()
if not args.no_build:
build()
bin_dir = target_dir() / "release"
work = Path(tempfile.mkdtemp(prefix="fieldcraft-flow-clip-"))
frames_dir = args.frames.resolve() if args.frames else work / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
args.out.mkdir(parents=True, exist_ok=True)
port = free_port()
app_log = open(work / "app.log", "w")
env = {**os.environ, "FIELDCRAFT_GPU": os.environ.get("FIELDCRAFT_GPU", "software")}
app = subprocess.Popen([str(bin_dir / "fieldcraft"), "--control", str(port)], stdout=app_log, stderr=subprocess.STDOUT, env=env)
ctl = None
try:
ctl = Control(port)
forces = solve(ctl)
if forces:
log(f"face forces: {json.dumps(forces)[:400]}")
shots = render_frames(ctl, frames_dir)
still(ctl, args.out / "flow-past-flow.png")
gif = args.out / "flow-past-transient.gif" if args.gif else None
encode(shots, frames_dir, args.out / "flow-past-transient.mp4", gif)
finally:
if ctl:
ctl.close()
app.terminate()
try:
app.wait(timeout=10)
except subprocess.TimeoutExpired:
app.kill()
app_log.close()
if not args.frames:
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
main()