Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a824c1eb75 | ||
|
|
ac05e26934 | ||
|
|
4e0601611c | ||
|
|
07708a4e2a | ||
|
|
a4c4b411e5 | ||
|
|
5eb7640b54 | ||
|
|
003abc2149 | ||
|
|
136a3e9a2f | ||
|
|
e70bd52409 | ||
|
|
132b63d24d | ||
|
|
72b7fc385b | ||
|
|
292747e50b | ||
|
|
2e4266cb0a |
No files matched your search
@@ -0,0 +1,41 @@
|
||||
name: Bug report
|
||||
description: Something in SolidCraft does the wrong thing
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment.
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What did you expect, and what happened instead?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: |
|
||||
What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
placeholder: |
|
||||
1. Sketch a circle on the XY plane
|
||||
2. Extrude it 10 mm
|
||||
3. Fillet the top edge with 6 mm
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: SolidCraft version and OS
|
||||
description: Help → About shows the version.
|
||||
placeholder: 0.15.0, Windows 11
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: media
|
||||
attributes:
|
||||
label: Screenshots or recording
|
||||
description: |
|
||||
For anything you can see in the app, a screenshot or short recording of a small example helps the most. Please leave out private designs and personal details.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Help reproduce and fix a SolidCraft problem
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
What did you expect, and what happened instead?
|
||||
|
||||
## Reproduction
|
||||
|
||||
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
|
||||
When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
|
||||
@@ -1,3 +1,5 @@
|
||||
# Blank issues stay on: the app's Report an Issue opens issues/new?title=…&body=… with the report
|
||||
# filled in (crates/ui-egui/src/issue_report.rs), which GitHub drops when blank issues are off.
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions and chat
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Feature request
|
||||
description: Something SolidCraft should do
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: What are you trying to do
|
||||
description: The part or workflow you have in mind, and where SolidCraft gets in the way today.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: How you imagine it working
|
||||
description: Optional. Describe the behaviour in your own words; please don't paste another product's documentation.
|
||||
@@ -211,8 +211,8 @@ jobs:
|
||||
run: cargo xtask web-offline-test
|
||||
- name: Affected-package selection self-test
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Claim bot tests
|
||||
run: node --test scripts/claim/*.test.js
|
||||
- name: Claim and triage bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Sending a change"), and a daily sweep that
|
||||
# frees claims nobody is working on. The logic is in scripts/claim/claim.js (tested in ci.yml).
|
||||
# frees claims nobody is working on. A pull request that references an issue (`<summary> (#N)`,
|
||||
# `Fixes #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:
|
||||
@@ -16,7 +21,7 @@ permissions:
|
||||
|
||||
# One run at a time per issue, so two simultaneous /claim comments can't both win.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number || 'sweep' }}
|
||||
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -38,6 +43,9 @@ jobs:
|
||||
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'}`);
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 });
|
||||
}
|
||||
@@ -7,3 +7,4 @@
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
dist/
|
||||
@@ -129,7 +129,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
|
||||
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
|
||||
labels the issue `in progress` (or says on your PR who holds it). Don't claim by editing files.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
|
||||
+27
-5
@@ -258,9 +258,10 @@ and a fresh CI run:
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
|
||||
on the other one).
|
||||
- Then comment `/claim` on the issue so the bot records the claimant and adds the `in progress`
|
||||
label. **Do both:** the draft PR establishes ownership and `/claim` marks the issue as taken.
|
||||
Reference `#N` in the PR so the bot can find it; a comment alone does not replace the draft PR.
|
||||
- The bot records the claim when the PR is opened: it assigns you, adds `in progress` and
|
||||
says so on the issue. It reads `#N` in the PR title and `Fixes #N` in the body. If someone
|
||||
already holds the issue, or an older open PR references it, it says so on your PR instead.
|
||||
- Want to stake a claim before you have a branch? Comment `/claim` on the issue (below).
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim.
|
||||
- Mark the PR ready for review when it is done.
|
||||
@@ -270,10 +271,10 @@ and a fresh CI run:
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
The `/claim` bot assigns you, adds the `in progress` label, and replies with its automatic cleanup
|
||||
Commenting `/claim` assigns you, adds the `in progress` label, and replies with its automatic cleanup
|
||||
deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days
|
||||
without pushes. These automatic cleanup deadlines are separate from the daily progress and
|
||||
72-hour takeover policy above; follow the draft PR then `/claim` sequence before starting work.
|
||||
72-hour takeover policy above. A draft PR that references the issue claims it the same way.
|
||||
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
|
||||
taken are never freed automatically.
|
||||
|
||||
@@ -303,6 +304,27 @@ owning one area, with a human setting direction and reporting bugs. Their workin
|
||||
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
|
||||
long as the change meets the rules above.
|
||||
|
||||
## Labels and triage
|
||||
|
||||
The triage bot labels every new issue and PR (`.github/workflows/triage.yml`). It only adds labels;
|
||||
correct them freely and it won't put them back.
|
||||
|
||||
- **PRs** get `area:*` from the paths they change (`crates/kernel` and `crates/geom` →
|
||||
`area:kernel`, `crates/ui-egui` and the apps → `area:ui`, `.github/`, `scripts/`, `docs/` →
|
||||
`area:infra`, and so on).
|
||||
- **Issues** get `bug` (in-app reports and the bug template), `enhancement` (feature requests),
|
||||
`priority:P1` for crashes, and an `area:*` from the title or the reported command. Until it has
|
||||
a priority, an issue carries `needs-triage`; setting a priority removes it.
|
||||
- **Maintainers** comment `/triage area:kernel P1` (any existing label; `P0`–`P3` for a
|
||||
priority, which replaces the old one; `-label` removes one) to sort an issue or PR.
|
||||
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
|
||||
the steps (and the version, when it's missing). An app report that arrived empty is asked to
|
||||
paste the report the app copied to the clipboard. 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, app reports with their details, and maintainers' issues
|
||||
are never asked.
|
||||
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/solidcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
|
||||
Generated
+1
@@ -4278,6 +4278,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# OneCAD rebrand map (identity layer)
|
||||
|
||||
OneCAD is the OneBSpace fork of SolidCraft. Since the 20.0.1.0.6 build the rebrand is
|
||||
**source-level** for everything the user can see or click; only the compatibility layer keeps
|
||||
upstream names (so `.solidcraft` files, the `window.solidcraft` JS API and the crate layout stay
|
||||
upstream-compatible). Files tagged `OneBSpace (OneCAD)` are downstream-only deltas.
|
||||
|
||||
## Rebranded in source (user-visible)
|
||||
|
||||
| Layer | Change |
|
||||
|---|---|
|
||||
| App bar | upstream vector mark dropped; `OneCAD` wordmark text (`toolbar.rs`), shown in every width |
|
||||
| Start page | title `SolidCraft` → `OneCAD`; the outbound links (Documentation, Report an Issue, What's New) are removed (`home.rs`) |
|
||||
| Launch flow | the start page no longer shows on launch — the app opens straight into the workspace (`main.rs`); the house button still shows Home |
|
||||
| Loading splash | upstream `loading-logo.svg` image is not shipped; the splash is a plain `OneCAD` wordmark (`index.html` + `loading.css`) and the 3.6 s minimum hold is gone (`LOADING_MIN_MS = 0`) |
|
||||
| Help menu | `Documentation` (docs.solidcraft.io) and `Report an Issue…` (GitHub draft) items removed; About / Keyboard Shortcuts / Crash Reports stay |
|
||||
| About window | `About OneCAD`, OneCAD description, OneCAD diagnostics header |
|
||||
| Issue/crash reports | fully local: no GitHub URL is opened anywhere; "Open GitHub draft" replaced by a clipboard `Copy report` (`issue_report.rs`); the app-bar bug button tooltip no longer mentions GitHub |
|
||||
| Visible strings | status lines, recovery dialog, clipboard text (`OneCAD sketch geometry: …`), AI-chat errors, settings copy — `SolidCraft` → `OneCAD` in code literals and in every `assets/locales/*.json` value |
|
||||
| Web page | `<title>OneCAD`, social-card meta and PWA manifest/icons dropped from the bundle (as before) |
|
||||
|
||||
## Stays upstream (compatibility & scale)
|
||||
|
||||
- **Format tag `solidcraft/N`** inside the `.solidcraft` ZIP container and the `.solidcraft`
|
||||
extension (the one_cad registry associates `extensions: ['solidcraft']`).
|
||||
- **`window.solidcraft` JS API global** — the public browser API surface the one_cad bridge drives.
|
||||
- **VFS paths** (`/opfs/`, `/downloads/`, plus the OneCAD additions `/odoo/…`) and OPFS/local-storage keys.
|
||||
- **Crate/binary names and Cargo workspace layout** — untouched; ~876 files mention "solidcraft";
|
||||
renaming them would make every upstream sync a merge conflict. (`SOLIDCRAFT_COMMIT` /
|
||||
`SOLIDCRAFT_BUILD_DATE` env names stay; their About text renders as OneCAD.)
|
||||
- Desktop-only updater internals (registry key, installer file names) keep upstream strings —
|
||||
unreachable from the web build.
|
||||
@@ -24,6 +24,10 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
# OneBSpace (OneCAD): std::time::Instant PANICS on wasm32-unknown-unknown
|
||||
# ("time not implemented on this platform") — every picker deadline in
|
||||
# services_odoo.rs must use web_time::Instant (performance.now() on wasm).
|
||||
web-time = "1"
|
||||
serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
|
||||
@@ -3,27 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolidCraft</title>
|
||||
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="SolidCraft">
|
||||
<meta property="og:title" content="SolidCraft: try it in your browser">
|
||||
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:url" content="https://solidcraft.ai/">
|
||||
<meta property="og:image" content="https://docs.solidcraft.io/og.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="SolidCraft: try it in your browser">
|
||||
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta name="twitter:image" content="https://docs.solidcraft.io/og.png">
|
||||
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<title>OneCAD</title>
|
||||
<meta name="description" content="OneCAD — parametric CAD of the One B OS workspace, in Rust.">
|
||||
<!-- OneBSpace (OneCAD): upstream's social-card meta (solidcraft.ai / docs.solidcraft.io)
|
||||
and PWA manifest + icons are gone — this is our own app inside the Odoo iframe. -->
|
||||
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
|
||||
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/styles/web/loading-logo.svg" />
|
||||
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
|
||||
<meta name="theme-color" content="#343840">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
@@ -33,15 +20,31 @@
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png" data-target-path="icons/32x32" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" data-target-path="icons/256x256" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png" data-target-path="icons/512x512" />
|
||||
<!-- OneBSpace (OneCAD): the Odoo Documents host bridge. Copied verbatim
|
||||
(not hashed): the framework's bundleVersion ?v= on the iframe URL is
|
||||
the cache buster, like every other OneBSpace app bundle. The build
|
||||
script compiles the erasable-syntax TS (src/odoo/bridge.ts and
|
||||
src/odoo/index.ts) to odoo/bridge.js / odoo/index.js in dist;
|
||||
explorer.js is copied as-is (data-trunk copy-dir, same tag shape as
|
||||
every other entry above). -->
|
||||
<link data-trunk rel="copy-dir" href="src/odoo" data-target-path="odoo" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solidcraft_canvas"></canvas>
|
||||
<div id="solidcraft_loading" role="status"><img src="loading-logo.svg" alt=""><span class="sr-only">Loading SolidCraft…</span></div>
|
||||
<div id="solidcraft_loading" role="status"><span class="wordmark">OneCAD</span><span class="sr-only">Loading OneCAD…</span></div>
|
||||
<script>
|
||||
// Offline use and updates (sw.js; written into release builds by `cargo xtask web-offline`).
|
||||
if ("serviceWorker" in navigator) {
|
||||
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
|
||||
}
|
||||
// OneBSpace (OneCAD): 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.6").catch((e) => console.warn("OneCAD odoo bridge:", e));
|
||||
}
|
||||
} catch (e) { /* cross-origin parent (standalone) — skip */ }
|
||||
// Install as an app: the browser's offer can come before the app runs, so it is kept here for
|
||||
// the app's File ▸ Install app… item (src/install.rs). Installed, the browser makes no more offers.
|
||||
addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); window.solidcraftInstall = e; });
|
||||
|
||||
@@ -295,6 +295,12 @@ pub fn start(files: &Rc<Files>) {
|
||||
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
|
||||
pub fn flush(files: &Rc<Files>) {
|
||||
for (path, bytes) in vfs::take_writes() {
|
||||
// OneBSpace (OneCAD): in the Odoo shell, writes under /odoo/ go to
|
||||
// the bound Documents record and /downloads/ exports go to the
|
||||
// "Exports" folder (air-gapped: no device IO through the VFS).
|
||||
if crate::services_odoo::handle_write(&path, &bytes) {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.strip_prefix(OPFS) {
|
||||
let name = name.to_string();
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! SolidCraft in the browser.
|
||||
//! OneCAD in the browser.
|
||||
//!
|
||||
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
@@ -12,6 +12,10 @@ mod api;
|
||||
mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod install;
|
||||
// OneBSpace (OneCAD): Odoo Documents host integration — Services overrides
|
||||
// (pickers → the JS bridge), VFS /odoo/ + /downloads/ drain, asset mirrors.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod services_odoo;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod space_mouse;
|
||||
|
||||
@@ -34,14 +38,15 @@ mod web {
|
||||
/// draws it with reversed depth, whose precision follows the distance from the eye (#1434).
|
||||
const DEPTH_BITS: u8 = 32;
|
||||
|
||||
/// The loading animation (assets/styles/web/loading-logo.svg) settles into the finished mark
|
||||
/// about 3.2 s after the page starts; this adds a short hold. The app doesn't land before it.
|
||||
const LOADING_MIN_MS: f64 = 3600.0;
|
||||
/// OneBSpace (OneCAD): upstream held the loading logo animation on screen for 3.6 s
|
||||
/// (LOADING_MIN_MS = 3600) — the "splash opener". The wordmark splash is dismissed as
|
||||
/// soon as the app is ready; the fade itself stays.
|
||||
const LOADING_MIN_MS: f64 = 0.0;
|
||||
/// The fade from the loading screen into the app.
|
||||
const LOADING_FADE_MS: i32 = 300;
|
||||
|
||||
/// Take the loading screen away once the animation has played to the finished mark: wait out
|
||||
/// what is left of `LOADING_MIN_MS`, fade it, then remove it.
|
||||
/// Take the loading screen away as soon as the app is ready (OneBSpace (OneCAD): no
|
||||
/// forced minimum hold): fade it, then remove it.
|
||||
fn dismiss_loading(el: web_sys::Element) {
|
||||
let Some(window) = web_sys::window() else {
|
||||
el.remove();
|
||||
@@ -120,6 +125,10 @@ mod web {
|
||||
for (text, error) in self.files.messages.borrow_mut().drain(..) {
|
||||
self.app.set_status(text, error);
|
||||
}
|
||||
// OneBSpace (OneCAD): CAD assets brought in from the computer are
|
||||
// mirrored into the Documents "Assets" folder in the Odoo host
|
||||
// (before the incoming queue is drained below).
|
||||
crate::services_odoo::mirror_imports(&self.files);
|
||||
let mut incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
// A glTF file's buffers picked with it are kept next to it, not opened.
|
||||
incoming.retain_mut(|(name, bytes, _)| {
|
||||
@@ -523,6 +532,8 @@ mod web {
|
||||
install_app: Some(crate::install::service(&cc.egui_ctx)),
|
||||
..Default::default()
|
||||
};
|
||||
// OneBSpace (OneCAD): install odoo services on wasm
|
||||
let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());
|
||||
// Thumbnails of saves and start-page cards.
|
||||
solidcraft_ui_egui::gpu::install_cpu_picture_renderer();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
@@ -544,11 +555,11 @@ mod web {
|
||||
if !query().contains("sample") {
|
||||
// The empty design takes Units of new designs (#1205). The sample
|
||||
// part is built in millimetres and is left that way.
|
||||
// OneBSpace (OneCAD): no start page — the app opens straight
|
||||
// into the workspace (the house button still shows Home).
|
||||
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
|
||||
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("OneCAD browser API: {e:?}"))?;
|
||||
Ok(Box::new(Shell {
|
||||
app,
|
||||
api,
|
||||
@@ -569,7 +580,7 @@ mod web {
|
||||
match result {
|
||||
Ok(()) => dismiss_loading(el),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
el.set_inner_html(&format!("<p>OneCAD failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,947 @@
|
||||
// OneBSpace (OneCAD) — odoo host bridge port
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — Proprietary.
|
||||
* OneCAD 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", kind: "odoo-documents", id, method, payload}
|
||||
* posted on window.parent (same-origin Odoo shell iframe)
|
||||
* reply : {source: "one-cad-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 "onecad: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.solidcraft (below).
|
||||
* REQUEST QUEUE: entries are processed
|
||||
* strictly FIFO, one at a time, and each
|
||||
* id is answered exactly once.
|
||||
*
|
||||
* EMIT "onecad: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 "onecad: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.onecadHost.{ready,host,documentId,name}.
|
||||
*
|
||||
* EMIT "onecad: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.onecadHost.consumePendingOpen()
|
||||
* (returns the detail once, then null).
|
||||
*
|
||||
* JS -> APP window.solidcraft.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 (scripts/build_onecad_addon.py) 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 .solidcraft 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";
|
||||
export const HOST_SOURCE_TAG = "one-cad-host";
|
||||
export const BRIDGE_KIND = "odoo-documents";
|
||||
|
||||
/** Window CustomEvent names of the Rust facade contract (see header). */
|
||||
export const EVT_HOST_REQUEST = "onecad:host-request";
|
||||
export const EVT_HOST_RESPONSE = "onecad:host-response";
|
||||
export const EVT_HOST_READY = "onecad:host-ready";
|
||||
export const EVT_DOCUMENT_OPENED = "onecad:document-opened";
|
||||
|
||||
/** window.solidcraft 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 "onecad: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:solidcraft:bind:";
|
||||
const OWNED_EXTENSION = ".solidcraft";
|
||||
|
||||
/** 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 SolidcraftApi {
|
||||
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 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(/\.(solidcraft|solvecraft)$/i, "");
|
||||
}
|
||||
|
||||
function detectInFrame(): boolean {
|
||||
try {
|
||||
return window.parent !== window;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function solidcraftApi(): SolidcraftApi | null {
|
||||
const holder = window as unknown as { solidcraft?: SolidcraftApi };
|
||||
const api = holder.solidcraft;
|
||||
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 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 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 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 "onecad: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("[onecad-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("[onecad-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 odoo bridge: standalone (no Odoo host) — explorer unavailable");
|
||||
}
|
||||
if (!this.opener) {
|
||||
throw new Error("OneCAD odoo bridge: no explorer dialog wired (setExplorerOpener)");
|
||||
}
|
||||
return this.opener(options);
|
||||
}
|
||||
|
||||
private async appSerialize(): Promise<Uint8Array> {
|
||||
const api = solidcraftApi();
|
||||
if (!api || !api.request) {
|
||||
throw new Error(
|
||||
"OneCAD odoo bridge: no document bytes given and window.solidcraft is unavailable",
|
||||
);
|
||||
}
|
||||
const out = await api.request(APP_SERIALIZE_METHOD);
|
||||
const bytes = normalizeBytes(out);
|
||||
if (!bytes) {
|
||||
throw new Error("OneCAD odoo bridge: window.solidcraft did not return document bytes");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async appInfo(): Promise<{ name?: string }> {
|
||||
const api = solidcraftApi();
|
||||
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 (.solidcraft filter) -> read 300 s. */
|
||||
async openFromDocuments(): Promise<OpenResult | null> {
|
||||
const choice = await this.askExplorer({ mode: "open", extensions: ["solidcraft", "solvecraft"] });
|
||||
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 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 : ["solidcraft", "solvecraft"]);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
// OneBSpace (OneCAD) — odoo host bridge port
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — Proprietary.
|
||||
* OneCAD 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 (.solidcraft chip)
|
||||
* open — "Open from Documents": folder tree + files filtered to .solidcraft
|
||||
* 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.6";
|
||||
|
||||
const STYLE_ID = "oca-explorer-style";
|
||||
|
||||
const MODES = {
|
||||
save: {
|
||||
heading: "Save to Documents",
|
||||
description: "Choose a folder in your Odoo drive for this .solidcraft document.",
|
||||
action: "Save",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad:documents:last-folder:save",
|
||||
},
|
||||
open: {
|
||||
heading: "Open from Documents",
|
||||
description: "Browse your Odoo Documents drive and pick a .solidcraft document.",
|
||||
action: "Open",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad:documents:last-folder:open",
|
||||
extensions: new Set(["solidcraft", "solvecraft"]),
|
||||
},
|
||||
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: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: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{--oca-bg:#1b1e24;--oca-panel:#23272f;--oca-panel2:#2a2f39;--oca-hairline:#333945;",
|
||||
"--oca-text:#e8eaed;--oca-dim:#9aa3ad;--oca-accent:#4f8cff;--oca-accent-press:#3f76e0;",
|
||||
"--oca-danger:#e5534b;--oca-ok:#3fb26f}",
|
||||
".oca-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(--oca-text)}",
|
||||
".oca-backdrop{position:absolute;inset:0;background:rgba(8,10,14,.62)}",
|
||||
".oca-dialog{position:relative;width:min(860px,calc(100vw - 48px));height:min(560px,calc(100vh - 64px));",
|
||||
"background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:10px;",
|
||||
"box-shadow:0 24px 64px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
|
||||
".oca-header{padding:14px 18px 10px;border-bottom:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-title{font-size:15px;font-weight:600}",
|
||||
".oca-desc{margin-top:2px;color:var(--oca-dim);font-size:12px}",
|
||||
".oca-desc-error{color:var(--oca-danger)}",
|
||||
".oca-body{flex:1;display:flex;min-height:0}",
|
||||
".oca-aside{width:250px;flex:none;display:flex;flex-direction:column;border-right:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-tree-box{flex:1;overflow:auto;padding:8px 6px}",
|
||||
".oca-tree-label{margin:2px 8px 6px;color:var(--oca-dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}",
|
||||
".oca-tree-error{padding:8px}",
|
||||
".oca-tree-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;white-space:nowrap}",
|
||||
".oca-tree-row:hover{background:var(--oca-panel2)}",
|
||||
".oca-tree-row.oca-active{background:var(--oca-panel2);color:#fff}",
|
||||
".oca-tree-toggle{width:14px;height:14px;flex:none;color:var(--oca-dim);display:inline-flex;transition:transform .12s}",
|
||||
".oca-tree-toggle.oca-open{transform:rotate(90deg)}",
|
||||
".oca-tree-icon{display:inline-flex;color:var(--oca-accent)}",
|
||||
".oca-tree-name{overflow:hidden;text-overflow:ellipsis}",
|
||||
".oca-usage{padding:8px 14px;border-top:1px solid var(--oca-hairline);color:var(--oca-dim);font-size:11px}",
|
||||
".oca-main{flex:1;display:flex;flex-direction:column;min-width:0}",
|
||||
".oca-crumbs{display:flex;align-items:center;gap:2px;padding:8px 12px;border-bottom:1px solid var(--oca-hairline);overflow:hidden}",
|
||||
".oca-crumb{display:inline-flex;align-items:center;gap:5px;background:none;border:0;color:var(--oca-dim);",
|
||||
"font:inherit;padding:4px 7px;border-radius:6px;cursor:pointer}",
|
||||
".oca-crumb:hover:not(:disabled){background:var(--oca-panel2);color:var(--oca-text)}",
|
||||
".oca-crumb:disabled{opacity:.45;cursor:default}",
|
||||
".oca-crumb-current{color:var(--oca-text)}",
|
||||
".oca-crumb-sep{display:inline-flex;color:var(--oca-dim)}",
|
||||
".oca-files{flex:1;overflow:auto;padding:6px 8px}",
|
||||
".oca-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}",
|
||||
".oca-file-row:hover{background:var(--oca-panel2)}",
|
||||
".oca-file-row.oca-selected{background:rgba(79,140,255,.18);outline:1px solid var(--oca-accent)}",
|
||||
".oca-file-icon{display:inline-flex;color:var(--oca-dim)}",
|
||||
".oca-file-icon.oca-icon-owned{color:var(--oca-accent)}",
|
||||
".oca-file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".oca-file-size{color:var(--oca-dim);font-size:11px;flex:none}",
|
||||
".oca-empty{color:var(--oca-dim);padding:18px 10px;text-align:center}",
|
||||
".oca-savebar{border-top:1px solid var(--oca-hairline);padding:10px 14px;background:var(--oca-panel)}",
|
||||
".oca-name-row{display:flex;align-items:flex-end;gap:10px}",
|
||||
".oca-name-wrap{flex:1;min-width:0}",
|
||||
".oca-name-label{display:block;color:var(--oca-dim);font-size:11px;margin-bottom:4px}",
|
||||
".oca-name-field{display:flex;align-items:center;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;padding:0 8px}",
|
||||
".oca-name-field:focus-within{border-color:var(--oca-accent)}",
|
||||
".oca-name-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--oca-text);font:inherit;padding:7px 0}",
|
||||
".oca-suffix{color:var(--oca-dim);font-size:12px;user-select:none}",
|
||||
".oca-create-row{display:flex;gap:8px;margin-top:8px}",
|
||||
".oca-newfolder-input{flex:1;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;",
|
||||
"color:var(--oca-text);font:inherit;padding:6px 9px;outline:0}",
|
||||
".oca-newfolder-input:focus{border-color:var(--oca-accent)}",
|
||||
".oca-picker{display:flex;flex-direction:column;gap:8px}",
|
||||
".oca-pick-btn{display:inline-flex;align-items:center;gap:8px}",
|
||||
".oca-picked{color:var(--oca-dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".oca-footer{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;",
|
||||
"border-top:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-btn{display:inline-flex;align-items:center;gap:6px;font:inherit;padding:7px 14px;border-radius:6px;",
|
||||
"border:1px solid var(--oca-hairline);background:var(--oca-panel2);color:var(--oca-text);cursor:pointer}",
|
||||
".oca-btn:hover:not(:disabled){border-color:var(--oca-dim)}",
|
||||
".oca-btn:disabled{opacity:.45;cursor:default}",
|
||||
".oca-btn-accent{background:var(--oca-accent);border-color:var(--oca-accent);color:#fff}",
|
||||
".oca-btn-accent:hover:not(:disabled){background:var(--oca-accent-press)}",
|
||||
".oca-btn-ghost{background:none;border-color:transparent;color:var(--oca-dim)}",
|
||||
".oca-btn-ghost:hover:not(:disabled){color:var(--oca-text)}",
|
||||
".oca-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="oca-file-icon oca-icon-owned">' + ICONS.cube + "</span>";
|
||||
}
|
||||
return '<span class="oca-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.solidcraft")
|
||||
* 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("[onecad-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" ? ".solidcraft" : "";
|
||||
let defaultName = typeof opts.defaultName === "string" && opts.defaultName.trim() !== ""
|
||||
? opts.defaultName.trim()
|
||||
: mode === "save"
|
||||
? "untitled.solidcraft"
|
||||
: "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: "oca-backdrop" });
|
||||
const dialog = h("div", { class: "oca-dialog", role: "dialog", "aria-modal": "true" });
|
||||
const root = h("div", { class: "oca-explorer-root" }, backdrop, dialog);
|
||||
|
||||
const descEl = h("div", { class: "oca-desc" }, config.description);
|
||||
const header = h("div", { class: "oca-header" },
|
||||
h("div", { class: "oca-title" }, config.heading), descEl);
|
||||
|
||||
const treeLabel = h("p", { class: "oca-tree-label" }, "Loading drive…");
|
||||
const treeBox = h("div", { class: "oca-tree-box" }, treeLabel);
|
||||
const usageEl = h("div", { class: "oca-usage", style: "display:none" });
|
||||
const aside = h("div", { class: "oca-aside" }, treeBox, usageEl);
|
||||
|
||||
const crumbs = h("div", { class: "oca-crumbs" });
|
||||
const fileList = h("div", { class: "oca-files" });
|
||||
const main = h("div", { class: "oca-main" }, crumbs, fileList);
|
||||
const body = h("div", { class: "oca-body" }, aside, main);
|
||||
|
||||
/* name row (save + export) */
|
||||
const nameInput = h("input", {
|
||||
class: "oca-name-input",
|
||||
id: "onecad-explorer-name",
|
||||
type: "text",
|
||||
value: stem,
|
||||
placeholder: mode === "save" ? "Untitled" : "export",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const suffixChip = h("span", { class: "oca-suffix" }, suffix);
|
||||
const nameField = h("div", { class: "oca-name-field" }, nameInput, suffixChip);
|
||||
const nameRow = h("div", { class: "oca-name-row" },
|
||||
h("div", { class: "oca-name-wrap" },
|
||||
h("label", { class: "oca-name-label", for: "onecad-explorer-name" }, "File name"),
|
||||
nameField));
|
||||
|
||||
/* new-folder row (save + export; asset keeps it too — retargeting) */
|
||||
const newNameInput = h("input", {
|
||||
class: "oca-newfolder-input",
|
||||
type: "text",
|
||||
placeholder: "Folder name…",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const createBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" }, "Create");
|
||||
const cancelCreateBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
|
||||
const createRow = h("div", { class: "oca-create-row", style: "display:none" }, newNameInput, createBtn, cancelCreateBtn);
|
||||
const newFolderBtn = h("button", { class: "oca-btn", type: "button" },
|
||||
h("span", { class: "oca-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: "oca-picked", text: "No file chosen yet." });
|
||||
const pickBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: ICONS.upload }), "Choose file…");
|
||||
const picker = h("div", { class: "oca-picker" }, pickBtn, pickedLabel, fileInput);
|
||||
|
||||
const savebar = h("div", { class: "oca-savebar" });
|
||||
if (wantsName) savebar.appendChild(nameRow);
|
||||
if (mode === "asset") savebar.appendChild(picker);
|
||||
savebar.appendChild(newFolderBtn);
|
||||
savebar.appendChild(createRow);
|
||||
|
||||
/* footer */
|
||||
const cancelBtn = h("button", { class: "oca-btn oca-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: "oca-btn oca-btn-accent", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: actionIcon }), config.action);
|
||||
const footer = h("div", { class: "oca-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: "oca-tree-label" }, "Loading drive…"));
|
||||
return;
|
||||
}
|
||||
if (treeError) {
|
||||
const retry = h("button", { class: "oca-btn", type: "button" }, "Retry");
|
||||
retry.addEventListener("click", () => boot());
|
||||
treeBox.appendChild(h("div", { class: "oca-tree-error" },
|
||||
h("p", { class: "oca-tree-label" }, treeError), retry));
|
||||
return;
|
||||
}
|
||||
treeBox.appendChild(h("p", { class: "oca-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: "oca-tree-row" + (folder.id === currentFolderId ? " oca-active" : ""),
|
||||
style: "padding-left:" + (8 + depth * 14) + "px",
|
||||
});
|
||||
const toggle = h("span", { class: "oca-tree-toggle" });
|
||||
if (hasChildren) {
|
||||
toggle.innerHTML = ICONS.chevronRight;
|
||||
toggle.classList.add("oca-has-children");
|
||||
if (isOpen) toggle.classList.add("oca-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: "oca-tree-icon", html: ICONS.folder }));
|
||||
row.appendChild(h("span", { class: "oca-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: "oca-crumb oca-crumb-root", type: "button" });
|
||||
driveBtn.appendChild(h("span", { class: "oca-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: "oca-crumb-sep", html: ICONS.chevronRight }));
|
||||
const crumb = h("button", {
|
||||
class: "oca-crumb" + (folder.id === currentFolderId ? " oca-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: "oca-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: "oca-file-row", type: "button" });
|
||||
row.insertAdjacentHTML("beforeend", '<span class="oca-file-icon oca-icon-owned">' + ICONS.folder + "</span>");
|
||||
row.appendChild(h("span", { class: "oca-file-name" }, folder.name));
|
||||
if (folder.system) {
|
||||
row.insertAdjacentHTML("beforeend",
|
||||
'<span class="oca-file-size" title="Protected OneBSpace folder">' + ICONS.lock + "</span>");
|
||||
}
|
||||
row.appendChild(h("span", { class: "oca-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: "oca-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: "oca-empty" }, "No compatible files here yet."));
|
||||
return;
|
||||
}
|
||||
for (const file of visibleFiles) {
|
||||
const row = h("button", {
|
||||
class: "oca-file-row" + (selectedFileId === file.id ? " oca-selected" : ""),
|
||||
type: "button",
|
||||
});
|
||||
row.insertAdjacentHTML("beforeend", fileIconFor(file, extensions));
|
||||
row.appendChild(h("span", { class: "oca-file-name" }, file.name));
|
||||
row.appendChild(h("span", { class: "oca-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("oca-desc-error");
|
||||
if (errorTimer) clearTimeout(errorTimer);
|
||||
errorTimer = setTimeout(() => {
|
||||
descEl.textContent = config.description;
|
||||
descEl.classList.remove("oca-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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// OneBSpace (OneCAD) — odoo host bridge port
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — Proprietary.
|
||||
* OneCAD 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, "onecad:document-opened");
|
||||
* 3. signal "onecad:host-ready" {host, documentId, name} exactly once.
|
||||
* The same state stays readable synchronously at window.onecadHost
|
||||
* (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: scripts/build_onecad_addon.py compiles this file (erasable-
|
||||
* syntax-only TS) 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.6";
|
||||
// @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.6";
|
||||
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 {
|
||||
onecadHostReady?: boolean;
|
||||
onecadHost?: {
|
||||
ready: boolean;
|
||||
host: boolean;
|
||||
documentId: number | null;
|
||||
name: string | null;
|
||||
consumePendingOpen: () => DocumentOpenedDetail | null;
|
||||
};
|
||||
solidcraft?: { request?: (method: string, params?: unknown) => Promise<unknown> };
|
||||
onecadOdoo?: {
|
||||
bootFile?: (recordId: number, name: string, bytes: Uint8Array) => string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const hostWindow = window as unknown as OneCadHostWindow;
|
||||
hostWindow.onecadHost = {
|
||||
ready: false,
|
||||
host: false,
|
||||
documentId: null,
|
||||
name: null,
|
||||
consumePendingOpen: () => bridge.consumePendingOpen(),
|
||||
};
|
||||
|
||||
/* services_odoo.rs polls window.onecadHostReady (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.onecadHostReady = true;
|
||||
} catch (error) {
|
||||
console.warn("[onecad-odoo] could not set onecadHostReady:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* Deep-link opener: stage the record bytes into the engine VFS via the
|
||||
* window.onecadOdoo.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.solidcraft;
|
||||
const seam = hostWindow.onecadOdoo;
|
||||
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(
|
||||
"[onecad-odoo] deep-link open failed:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
console.warn("[onecad-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(
|
||||
"[onecad-odoo] host did not answer in time — continuing without Odoo Documents:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.info("[onecad-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("[onecad-odoo] deep-link read failed:", error);
|
||||
}
|
||||
}
|
||||
const binding = bridge.getCurrentBinding();
|
||||
|
||||
const detail: HostReadyDetail = {
|
||||
host,
|
||||
documentId: binding.documentId,
|
||||
name: binding.name,
|
||||
};
|
||||
const state = hostWindow.onecadHost;
|
||||
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 {};
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
|
||||
* OneBSpace (OneCAD): odoo/onecad-odoo.js — the app-side host glue.
|
||||
* ---------------------------------------------------------------------
|
||||
* Entry module loaded by index.html ONLY inside a frame (standalone runs
|
||||
* never import it). It is the middle layer between the Rust app
|
||||
* (apps/solidcraft-web/src/services_odoo.rs, which installs
|
||||
* `window.onecadOdoo` — the resolve/bootFile glue) and the wasm_app_
|
||||
* framework Documents bridge (odoo/bridge.ts compiled to bridge.js):
|
||||
*
|
||||
* request(id, kind, payloadJson) Rust picker → explorer → resolve
|
||||
* kind "open" → Documents explorer → bridge.read → Rust inserts
|
||||
* bytes at /odoo/doc/<recordId>/<name>
|
||||
* kind "save" → Documents explorer (Save) → token → Rust returns
|
||||
* /odoo/pending/<token>/<name>
|
||||
* kind "program" → asset explorer (Assets folder) → like "open"
|
||||
* write(path, bytes) VFS drain → bridge write / saveExport
|
||||
* /odoo/doc/<recordId>/<name> → bridge.write({recordId}) in place
|
||||
* /odoo/pending/<token>/<name> → token meta (localStorage) → first
|
||||
* write creates the record, later
|
||||
* writes hit the same record (Ctrl+S)
|
||||
* /downloads/<name> → bridge.saveExport into "Exports"
|
||||
* import(name, bytes) computer-imported CAD assets mirrored
|
||||
* into the "Assets" folder (sig-deduped)
|
||||
*
|
||||
* Path schemes on the emulated filesystem (the one-photo conventions):
|
||||
* /odoo/doc/<recordId>/<name> an existing Documents record
|
||||
* /odoo/pending/<token>/<name> a chosen destination not yet written
|
||||
* /downloads/<name> an export → Documents "Exports"
|
||||
*
|
||||
* Deep-link boot gate: the shell's iframe URL carries ?document=<id> when
|
||||
* the app was opened by clicking an owned file in Documents. The record is
|
||||
* read, handed to Rust (bootFile inserts it into the engine's VFS at
|
||||
* /odoo/doc/...) and opened like any opened file (doc.open / inserts).
|
||||
*/
|
||||
import {
|
||||
bridge,
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
guardCap,
|
||||
extensionOf,
|
||||
sanitizeStem,
|
||||
withExtension,
|
||||
} from "./bridge.js";
|
||||
import { openDocumentsExplorer } from "./explorer.js";
|
||||
|
||||
/* localStorage stores (the binding cache; Documents stays the truth) */
|
||||
const PENDING_KEY = "one-cad:odoo:pending-tokens";
|
||||
const IMPORT_SIGS_KEY = "one-cad:odoo:import-sigs";
|
||||
const SAVE_FOLDER_KEY = "one-cad:documents:last-folder:save";
|
||||
const EXPORT_FOLDER_KEY = "one-cad:documents:last-folder:export";
|
||||
const OPEN_FOLDER_KEY = "one-cad:documents:last-folder:open";
|
||||
|
||||
/* Commands the app understands on an emulated path (io crate naming). */
|
||||
const DESIGN_EXTENSIONS = new Set(["solidcraft", "solvecraft", "json"]);
|
||||
const STEP_EXTENSIONS = new Set(["step", "stp", "iges", "igs", "brep"]);
|
||||
const MESH_EXTENSIONS = new Set(["stl", "obj", "3mf", "gltf", "glb", "ply"]);
|
||||
const ASSET_EXTENSIONS = new Set([
|
||||
...STEP_EXTENSIONS,
|
||||
...MESH_EXTENSIONS,
|
||||
"dxf", "svg", "urdf",
|
||||
]);
|
||||
|
||||
function embedded() {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.parent || window.parent === window) return false;
|
||||
// Same-origin shell under the Odoo web client (family env.ts rule).
|
||||
const path = window.parent.location.pathname;
|
||||
return path.startsWith("/odoo") || path.startsWith("/web") || /[\?&]odoo-host=1/.test(window.location.search);
|
||||
} catch (e) {
|
||||
// Cross-origin parent cannot happen in the same-origin shell; treat any
|
||||
// throw (parent probe blocked) as framed.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* localStorage stores */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function loadMap(key) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveMap(key, value) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
/* private mode / quota — memory only for this session */
|
||||
}
|
||||
}
|
||||
|
||||
let tokens = loadMap(PENDING_KEY);
|
||||
function saveTokens() {
|
||||
saveMap(PENDING_KEY, tokens);
|
||||
}
|
||||
|
||||
function newToken() {
|
||||
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
/** Pending-save tokens survive reloads so Ctrl+S keeps hitting the same
|
||||
* record even after the token map left memory (one-photo convention). */
|
||||
function tokenOf(path) {
|
||||
const rest = path.slice("/odoo/pending/".length);
|
||||
const slash = rest.indexOf("/");
|
||||
return slash === -1 ? rest : rest.slice(0, slash);
|
||||
}
|
||||
|
||||
function baseName(path) {
|
||||
const withoutSlash = path.replace(/\/+$/, "");
|
||||
const slash = withoutSlash.lastIndexOf("/");
|
||||
return slash === -1 ? withoutSlash : withoutSlash.slice(slash + 1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Rust glue plumbing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** The Rust side installs window.onecadOdoo (services_odoo.rs). Requests
|
||||
* arriving before it are queued — boot-order races are impossible. */
|
||||
function rustGlue() {
|
||||
return window.onecadOdoo || null;
|
||||
}
|
||||
|
||||
const requestQueue = [];
|
||||
|
||||
function dispatchRequest(id, kind, payloadJson) {
|
||||
if (!rustGlue()) {
|
||||
// The Rust glue is not installed yet (wasm still booting): queue it and
|
||||
// retry — boot-order races are impossible.
|
||||
requestQueue.push([id, kind, payloadJson]);
|
||||
window.setTimeout(pumpQueue, 250);
|
||||
return;
|
||||
}
|
||||
void handleRequest(id, kind, payloadJson);
|
||||
}
|
||||
|
||||
function pumpQueue() {
|
||||
if (!rustGlue()) return;
|
||||
while (requestQueue.length) {
|
||||
const [id, kind, payloadJson] = requestQueue.shift();
|
||||
void handleRequest(id, kind, payloadJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(id, kind, payloadJson) {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = payloadJson ? JSON.parse(payloadJson) : {};
|
||||
} catch (e) {
|
||||
payload = {};
|
||||
}
|
||||
try {
|
||||
await bridge.waitForHost();
|
||||
if (kind === "save") {
|
||||
const design = !!payload.design;
|
||||
const picked = await openDocumentsExplorer({
|
||||
mode: "save",
|
||||
defaultName: typeof payload.name === "string" && payload.name ? payload.name : "part.solidcraft",
|
||||
preferAppKey: design ? "projects" : "exports",
|
||||
lastFolderKey: design ? SAVE_FOLDER_KEY : EXPORT_FOLDER_KEY,
|
||||
});
|
||||
if (!picked || typeof picked.folderId !== "number") {
|
||||
rustGlue().resolve(id, null); // cancelled — not an error
|
||||
return;
|
||||
}
|
||||
const extension = extensionOf(payload.name || "");
|
||||
const name = withExtension(picked.name, extension);
|
||||
const token = newToken();
|
||||
tokens[token] = { folderId: picked.folderId, name };
|
||||
saveTokens();
|
||||
rustGlue().resolve(id, { token, name });
|
||||
return;
|
||||
}
|
||||
// "open" and "program": pick a record, read it, hand the bytes over.
|
||||
const preferAppKey = kind === "program" ? "assets" : "projects";
|
||||
const picked = await openDocumentsExplorer({
|
||||
mode: "open",
|
||||
extensions: Array.isArray(payload.extensions) ? payload.extensions : [],
|
||||
preferAppKey,
|
||||
lastFolderKey: OPEN_FOLDER_KEY,
|
||||
});
|
||||
if (!picked || typeof picked.recordId !== "number") {
|
||||
rustGlue().resolve(id, null);
|
||||
return;
|
||||
}
|
||||
const record = await bridge.read(picked.recordId);
|
||||
const bytes = base64ToBytes(record.dataB64);
|
||||
guardCap(bytes.length, record.name);
|
||||
// Foreign assets (outside the app tree) are copied into "Assets" so
|
||||
// imports always land there, like the family storage rules require.
|
||||
void mirrorToAssets(picked.recordId, record.name, bytes);
|
||||
rustGlue().resolve(id, { recordId: picked.recordId, name: record.name, bytes });
|
||||
} catch (error) {
|
||||
console.error("[one-cad odoo] request failed:", error);
|
||||
try {
|
||||
if (rustGlue()) rustGlue().resolve(id, null);
|
||||
} catch (e) {
|
||||
/* the Rust future times out on its own */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* VFS writes → Documents (saves + exports) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function handleWrite(path, bytes) {
|
||||
await hostReady;
|
||||
const dataB64 = bytesToBase64(bytes);
|
||||
if (path.indexOf("/odoo/doc/") === 0) {
|
||||
// /odoo/doc/<recordId>/<name> — in-place write-back (Ctrl+S).
|
||||
const rest = path.slice("/odoo/doc/".length);
|
||||
const recordId = Number(rest.split("/")[0]);
|
||||
if (!Number.isInteger(recordId) || recordId <= 0) throw new Error("bad document path: " + path);
|
||||
await bridge.write({ recordId, dataB64 });
|
||||
return;
|
||||
}
|
||||
if (path.indexOf("/odoo/pending/") === 0) {
|
||||
// /odoo/pending/<token>/<name> — first write creates the record, then
|
||||
// the token binds to it forever (the document_id → recordId session
|
||||
// map, kept in localStorage so reloads keep writing in place).
|
||||
const token = tokenOf(path);
|
||||
const meta = tokens[token];
|
||||
if (!meta) throw new Error("unknown save destination (stale token): " + token);
|
||||
const bound = Number.isInteger(meta.recordId) && meta.recordId > 0;
|
||||
const written = await bridge.write({
|
||||
recordId: bound ? meta.recordId : undefined,
|
||||
name: meta.name,
|
||||
folderId: bound ? undefined : meta.folderId,
|
||||
dataB64,
|
||||
extension: bound ? undefined : extensionOf(meta.name),
|
||||
});
|
||||
if (!bound) {
|
||||
meta.recordId = written.id;
|
||||
saveTokens();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (path.indexOf("/downloads/") === 0) {
|
||||
// Exports: no local device writes in the Odoo shell — every download
|
||||
// path is rerouted into the app's "Exports" folder.
|
||||
await bridge.saveExport({ name: baseName(path), dataB64 });
|
||||
return;
|
||||
}
|
||||
console.warn("[one-cad odoo] ignoring VFS write outside the Odoo schemes:", path);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Asset imports (computer → "Assets" folder) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let appTreeCache = null;
|
||||
|
||||
async function appAssetsFolder() {
|
||||
if (!appTreeCache) {
|
||||
const ping = await bridge.waitForHost();
|
||||
appTreeCache = (ping && ping.app) || {};
|
||||
}
|
||||
return typeof appTreeCache.assets === "number" ? appTreeCache.assets : appTreeCache.root;
|
||||
}
|
||||
|
||||
/** Copy a foreign record (picked from outside the app tree) into Assets. */
|
||||
async function mirrorToAssets(recordId, name, bytes) {
|
||||
try {
|
||||
if (typeof recordId !== "number" || DESIGN_EXTENSIONS.has(extensionOf(name))) return;
|
||||
const documents = await bridge.list();
|
||||
if ((documents.documents || []).some((doc) => Number(doc.id) === Number(recordId))) return; // already ours
|
||||
await writeAsset(name, bytes);
|
||||
} catch (error) {
|
||||
console.warn("[one-cad odoo] asset mirror skipped:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAsset(name, bytes) {
|
||||
guardCap(bytes.length, name);
|
||||
const folderId = await appAssetsFolder();
|
||||
const ext = extensionOf(name) || "bin";
|
||||
await bridge.write({
|
||||
name: sanitizeStem(name),
|
||||
dataB64: bytesToBase64(bytes),
|
||||
folderId,
|
||||
extension: ext,
|
||||
});
|
||||
}
|
||||
|
||||
/** Files brought in from the computer (files.rs incoming) land in Assets. */
|
||||
async function handleImport(name, bytes) {
|
||||
await hostReady;
|
||||
const sigs = loadMap(IMPORT_SIGS_KEY);
|
||||
const sig = name + ":" + bytes.length;
|
||||
if (sigs[sig]) return;
|
||||
sigs[sig] = 1;
|
||||
saveMap(IMPORT_SIGS_KEY, sigs);
|
||||
try {
|
||||
if (!ASSET_EXTENSIONS.has(extensionOf(name))) return;
|
||||
await writeAsset(name, bytes);
|
||||
} catch (error) {
|
||||
console.warn("[one-cad odoo] import into Assets failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Boot: handshake, deep link (?document=<id>), host-ready promise */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let hostReadyResolve;
|
||||
const hostReady = new Promise((resolve) => {
|
||||
hostReadyResolve = resolve;
|
||||
});
|
||||
|
||||
/** Open command for an emulated path, mirroring the app's own Open logic:
|
||||
* designs open, CAD formats are inserted, everything else just waits in
|
||||
* the VFS (the bytes are at /odoo/doc/… and stay retrievable). */
|
||||
function openCommandFor(name) {
|
||||
const ext = extensionOf(name);
|
||||
if (DESIGN_EXTENSIONS.has(ext)) return { command: "doc.open" };
|
||||
if (STEP_EXTENSIONS.has(ext)) return { command: "file.insert_step" };
|
||||
if (MESH_EXTENSIONS.has(ext)) return { command: "file.insert_mesh" };
|
||||
if (ext === "dxf" || ext === "svg") return { command: "sketch.insert_" + ext };
|
||||
return null;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
if (!embedded()) return;
|
||||
try {
|
||||
await bridge.waitForHost();
|
||||
} catch (error) {
|
||||
console.warn("[one-cad odoo] host handshake failed:", error);
|
||||
hostReadyResolve(false); // fail writes loudly instead of hanging
|
||||
return;
|
||||
}
|
||||
hostReadyResolve(true);
|
||||
pumpQueue();
|
||||
|
||||
const documentId = bridge.bootDocumentId();
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const record = await bridge.read(documentId);
|
||||
const bytes = base64ToBytes(record.dataB64);
|
||||
guardCap(bytes.length, record.name);
|
||||
// bootFile inserts the bytes into the engine VFS and returns the path.
|
||||
const path = rustGlue().bootFile(documentId, record.name, bytes);
|
||||
const open = openCommandFor(record.name);
|
||||
if (open && window.solidcraft && typeof window.solidcraft.execute === "function") {
|
||||
await window.solidcraft.execute(open.command, { path });
|
||||
} else if (!open) {
|
||||
console.info("[one-cad odoo] deep-linked file staged at", path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[one-cad odoo] deep-link boot failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public surface (called from services_odoo.rs) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
window.onecadOdooJs = {
|
||||
/** True once this module decided the app runs in the Odoo shell. */
|
||||
embedded: embedded(),
|
||||
/** Rust picker request: kind "open" | "save" | "program". */
|
||||
request(id, kind, payloadJson) {
|
||||
dispatchRequest(Number(id), String(kind || ""), payloadJson ? String(payloadJson) : "{}");
|
||||
},
|
||||
/** VFS write drained by services_odoo.rs (fire-and-forget save). */
|
||||
write(path, bytes) {
|
||||
handleWrite(String(path || ""), new Uint8Array(bytes)).catch((error) => {
|
||||
console.error("[one-cad odoo] save to Documents failed:", path, error);
|
||||
});
|
||||
},
|
||||
/** A CAD asset imported from the computer (mirrored into Assets). */
|
||||
import(name, bytes) {
|
||||
handleImport(String(name || ""), new Uint8Array(bytes)).catch((error) => {
|
||||
console.warn("[one-cad odoo] import failed:", name, error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (window.onecadOdooJs.embedded) {
|
||||
void boot();
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
// OneBSpace (OneCAD): Odoo Documents host integration.
|
||||
//! OneBSpace (OneCAD): 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
|
||||
//! [`solidcraft_ui_egui::Services`] is rerouted through the one_cad JS
|
||||
//! bridge instead of the browser/device:
|
||||
//!
|
||||
//! - `pick_open` / `pick_save` / `pick_program` ask the JS host glue to show
|
||||
//! the unified Documents explorer and resolve with an *emulated* VFS path:
|
||||
//! `/odoo/doc/<recordId>/<name>` (an opened Documents record, bytes staged
|
||||
//! in the engine VFS) or `/odoo/pending/<token>/<name>` (a Save-as
|
||||
//! destination; the JS side keeps the token → record binding so a later
|
||||
//! Ctrl+S writes back in place).
|
||||
//! - writes drained from the engine VFS under `/odoo/…` go to the bound
|
||||
//! Documents record and `/downloads/` exports go to the "Exports" folder —
|
||||
//! the air gap keeps bytes off the local device ([`handle_write`]).
|
||||
//! - `show_in_folder` is a no-op and `http_get` stays `None` (air gap: the
|
||||
//! shell has no local filesystem to reveal and no outbound HTTP).
|
||||
//!
|
||||
//! 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 JS bridge: CustomEvents)
|
||||
//!
|
||||
//! Rust → JS: `window.dispatchEvent(new CustomEvent("onecad:host-request",
|
||||
//! { detail }))` with `detail = { id: number, method: string, params: object }`.
|
||||
//!
|
||||
//! JS → Rust: `window.dispatchEvent(new CustomEvent("onecad:host-response",
|
||||
//! { detail }))` with `detail = { id, ok: boolean, result?: any, error?: string }`.
|
||||
//!
|
||||
//! The glue must attach its `onecad:host-response` listener *before* the app
|
||||
//! boots (index.html loads the bridge module ahead of the wasm) and set
|
||||
//! `window.onecadHostReady = 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; the picker future resolves `None` (no error).
|
||||
//!
|
||||
//! For JS→Rust binary staging (deep links `?document=<id>`) the glue may
|
||||
//! call `window.onecadOdoo.bootFile(recordId, name, uint8array) -> path`,
|
||||
//! which inserts the bytes into the engine VFS at `/odoo/doc/<id>/<name>`.
|
||||
//!
|
||||
//! # HOW TO WIRE (exact locations in the current tree)
|
||||
//!
|
||||
//! 1. `apps/solidcraft-web/src/main.rs`, module block (tagged
|
||||
//! `OneBSpace (OneCAD)`): `#[cfg(target_arch = "wasm32")] mod services_odoo;`
|
||||
//! 2. `apps/solidcraft-web/src/main.rs`, inside `web::start()`'s
|
||||
//! `WebRunner::start` app-install closure — immediately after the
|
||||
//! `let services = Services { .. };` literal and **before**
|
||||
//! `SolveApp::new(.., services)` (the tagged line
|
||||
//! `// OneBSpace (OneCAD): install odoo services on wasm`,
|
||||
//! currently main.rs line 537):
|
||||
//! `let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());`
|
||||
//! 3. **Exact VFS write drain point**: `apps/solidcraft-web/src/files.rs`
|
||||
//! `flush()` — the `for (path, bytes) in vfs::take_writes()` loop
|
||||
//! (files.rs line 297) hands every write to
|
||||
//! `crate::services_odoo::handle_write(&path, &bytes)` *first*
|
||||
//! (files.rs line 301); `true` consumes it (never OPFS, never a device
|
||||
//! download). Writes are enqueued by `vfs::write()`
|
||||
//! (crates/io/src/vfs.rs, wasm32 `imp::write`, line 84, which pushes the
|
||||
//! path onto the `WRITES` queue) and drained by `imp::take_writes()`
|
||||
//! (vfs.rs line 109).
|
||||
//! 4. `apps/solidcraft-web/src/main.rs` line 130 (`Shell::files_tick`):
|
||||
//! `crate::services_odoo::mirror_imports(&self.files)` mirrors CAD
|
||||
//! imports from the computer into the Documents "Assets" folder.
|
||||
|
||||
// OneBSpace (OneCAD) — 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::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context as TaskContext, Poll, Wake, Waker};
|
||||
use std::time::Duration;
|
||||
// OneBSpace (OneCAD): std::time::Instant::now() PANICS on
|
||||
// wasm32-unknown-unknown ("time not implemented on this platform") — the
|
||||
// whole app loop died the moment a picker (Open/Save/Import/Export) ran.
|
||||
// web_time::Instant maps to performance.now() on wasm, std on native.
|
||||
use web_time::Instant;
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{FilePickerFuture, Services};
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::files::{self, Files};
|
||||
|
||||
/// 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/";
|
||||
/// Rust → JS request event (see the WIRE PROTOCOL block above).
|
||||
const REQUEST_EVENT: &str = "onecad:host-request";
|
||||
/// JS → Rust response event (see the WIRE PROTOCOL block above).
|
||||
const RESPONSE_EVENT: &str = "onecad:host-response";
|
||||
const PICK_OPEN: &str = "odoo.pickOpen";
|
||||
const PICK_SAVE: &str = "odoo.pickSave";
|
||||
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 (the api.rs convention).
|
||||
const MAX_PENDING: usize = 64;
|
||||
/// A stuck shell (bridge JS missing, explorer broken) must not wedge the
|
||||
/// app's file picker forever: the future cancels itself after this long.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// Retry cadence while `window.onecadHostReady` has not appeared yet.
|
||||
const RETRY_MS: i32 = 250;
|
||||
/// How long to wait for the JS bridge module to become ready per request.
|
||||
const HOST_DEADLINE: Duration = Duration::from_secs(30);
|
||||
|
||||
/// CAD formats the app ingests (mirrors files.rs's input picker accept list
|
||||
/// plus glTF/URDF). 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 (the api.rs pending
|
||||
/// queue, adapted to Rust futures instead of JS promises).
|
||||
static PENDING: RefCell<HashMap<u64, Arc<Slot>>> = RefCell::new(HashMap::new());
|
||||
/// The request-id counter.
|
||||
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
|
||||
/// The `onecad:host-response` listener is installed exactly once.
|
||||
static LISTENER: Cell<bool> = const { Cell::new(false) };
|
||||
/// The `window.onecadOdoo` boot seam is installed exactly once.
|
||||
static BOOT: 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 answer slot: the response listener fills it, the future drains it.
|
||||
struct Slot {
|
||||
done: Cell<bool>,
|
||||
value: RefCell<Option<String>>,
|
||||
waker: RefCell<Option<Waker>>,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
fn complete(&self, value: Option<String>) {
|
||||
if self.done.get() {
|
||||
return;
|
||||
}
|
||||
*self.value.borrow_mut() = value;
|
||||
self.done.set(true);
|
||||
if let Some(waker) = self.waker.borrow_mut().take() {
|
||||
waker.wake();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The egui repaint waker (same pattern as ui-egui's own file picker).
|
||||
struct Repaint(egui::Context);
|
||||
|
||||
impl Wake for Repaint {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.0.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The future a `Services` picker closure returns; resolves with the
|
||||
/// emulated path once the shell answers, or `None` when cancelled/expired.
|
||||
struct PickerFuture {
|
||||
slot: Arc<Slot>,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
impl Future for PickerFuture {
|
||||
type Output = Option<String>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
|
||||
let this = self.get_mut();
|
||||
if this.slot.done.get() {
|
||||
return Poll::Ready(this.slot.value.borrow_mut().take());
|
||||
}
|
||||
if Instant::now() >= this.deadline {
|
||||
this.slot.complete(None);
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
*this.slot.waker.borrow_mut() = Some(cx.waker().clone());
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 boot seam and the response listener always
|
||||
/// (harmless standalone), the Services overrides only inside the shell.
|
||||
/// Call once at app start, right after the `Services` literal is built in
|
||||
/// `main.rs` and before `SolveApp::new` (see HOW TO WIRE above).
|
||||
pub fn install_odoo_services(services: Services, ctx: egui::Context) -> Services {
|
||||
install_boot_seam();
|
||||
install_response_listener(ctx.clone());
|
||||
if !embedded() {
|
||||
return services;
|
||||
}
|
||||
log::info!("OneCAD: Odoo shell detected — pickers and writes route through the Documents bridge");
|
||||
let open_ctx = ctx.clone();
|
||||
let save_ctx = ctx.clone();
|
||||
let program_ctx = ctx;
|
||||
Services {
|
||||
// Open/Insert pickers browse Odoo Documents (explorer, then a read).
|
||||
pick_open: Some(Box::new(move |extensions: &[&str]| {
|
||||
let params = Object::new();
|
||||
let list = Array::new();
|
||||
for ext in extensions {
|
||||
list.push(&JsValue::from_str(ext));
|
||||
}
|
||||
let _ = Reflect::set(¶ms, &"extensions".into(), &list.into());
|
||||
pick(PICK_OPEN, params, open_ctx.clone())
|
||||
})),
|
||||
// Saves ask the explorer once; the returned pending token binds the
|
||||
// design to its record (later Ctrl+S writes back in place).
|
||||
pick_save: Some(Box::new(move |name: &str, extensions: &[&str]| {
|
||||
let params = Object::new();
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
// Same design test as the upstream closure (main.rs).
|
||||
let design = extensions.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
let _ = Reflect::set(¶ms, &"design".into(), &JsValue::from_bool(design));
|
||||
pick(PICK_SAVE, params, save_ctx.clone())
|
||||
})),
|
||||
// The slicer program pick becomes an asset pick from the "Assets"
|
||||
// folder: the picked file is staged in the VFS (no host program
|
||||
// dialog exists in the shell).
|
||||
pick_program: Some(Box::new(move || pick(PICK_PROGRAM, Object::new(), program_ctx.clone()))),
|
||||
// Air gap: nothing local to reveal.
|
||||
show_in_folder: Some(Box::new(|_: &str| {})),
|
||||
// Air gap: no update checks over HTTP in the shell.
|
||||
http_get: None,
|
||||
..services
|
||||
}
|
||||
}
|
||||
|
||||
/// Park a picker slot and deliver the request to the shell; the returned
|
||||
/// future resolves with the emulated path (or `None` on cancel/timeout).
|
||||
fn pick(method: &'static str, params: Object, ctx: egui::Context) -> FilePickerFuture {
|
||||
if PENDING.with(|p| p.borrow().len()) >= MAX_PENDING {
|
||||
log::error!("OneCAD: too many pending bridge requests (maximum {MAX_PENDING})");
|
||||
return Box::pin(std::future::ready(None));
|
||||
}
|
||||
let id = next_id();
|
||||
let slot = Arc::new(Slot { done: Cell::new(false), value: RefCell::new(None), waker: RefCell::new(None) });
|
||||
PENDING.with(|p| p.borrow_mut().insert(id, slot.clone()));
|
||||
deliver(method, id, params, slot.clone());
|
||||
// The api.rs pattern: nudge the UI so the future is polled promptly.
|
||||
ctx.request_repaint();
|
||||
Box::pin(PickerFuture { slot, deadline: Instant::now() + REQUEST_TIMEOUT })
|
||||
}
|
||||
|
||||
fn next_id() -> u64 {
|
||||
NEXT_ID.with(|n| {
|
||||
let id = n.get();
|
||||
n.set(id + 1);
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
/// Deliver a request once `window.onecadHostReady` appears (the glue sets it
|
||||
/// after attaching its response listener), then cancel the slot if it never
|
||||
/// does. Answers arrive out-of-band via `onecad:host-response`.
|
||||
fn deliver(method: &'static str, id: u64, params: Object, slot: Arc<Slot>) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let deadline = Instant::now() + HOST_DEADLINE;
|
||||
loop {
|
||||
if slot.done.get() {
|
||||
return; // answered or cancelled while we were waiting
|
||||
}
|
||||
if host_ready() && dispatch_event(method, id, ¶ms) {
|
||||
return; // the shell owns the answer now
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
log::error!("OneCAD: the Odoo bridge never became ready; cancelling \"{method}\"");
|
||||
slot.complete(None);
|
||||
return;
|
||||
}
|
||||
sleep_ms(RETRY_MS).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn host_ready() -> bool {
|
||||
web_sys::window()
|
||||
.map(|w| Reflect::get(&w, &"onecadHostReady".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn sleep_ms(ms: i32) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
/// `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 `onecad:host-response` listener once: responses complete the
|
||||
/// parked picker slots (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 {
|
||||
slot.complete(path_from_result(&result));
|
||||
} else {
|
||||
log::warn!("OneCAD: the shell refused the request: {error}");
|
||||
slot.complete(None);
|
||||
}
|
||||
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: 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `onecad:host-response` result onto the emulated path the picker
|
||||
/// future receives (see the WIRE PROTOCOL table above).
|
||||
fn path_from_result(result: &JsValue) -> Option<String> {
|
||||
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());
|
||||
}
|
||||
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);
|
||||
}
|
||||
// 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(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(path);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Stage a picked record's bytes in the engine VFS 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")));
|
||||
vfs::insert(&path, bytes);
|
||||
Some(path)
|
||||
}
|
||||
|
||||
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 VFS: 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 `files.rs::flush`, the exact
|
||||
/// `vfs::take_writes()` drain point): consume writes the shell must not turn
|
||||
/// into device IO. `/odoo/…` writes go to the bound Documents record (or the
|
||||
/// pending token's destination on first write), `/downloads/` exports go to
|
||||
/// the "Exports" folder via saveExport. Returns true when the write was
|
||||
/// consumed (standalone: always false, upstream behavior untouched).
|
||||
pub fn handle_write(path: &str, bytes: &[u8]) -> bool {
|
||||
if !embedded() {
|
||||
return false;
|
||||
}
|
||||
let export = path.strip_prefix(files::DOWNLOADS).map(str::to_string);
|
||||
let is_odoo = path.starts_with(ODOC) || path.starts_with(OPENDING);
|
||||
if !is_odoo && export.is_none() {
|
||||
return false;
|
||||
}
|
||||
let params = Object::new();
|
||||
if let Some(name) = &export {
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
} else {
|
||||
let _ = Reflect::set(¶ms, &"path".into(), &JsValue::from_str(path));
|
||||
}
|
||||
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes).into());
|
||||
let method = if export.is_some() { SAVE_EXPORT } else { WRITE };
|
||||
if !dispatch_event(method, next_id(), ¶ms) {
|
||||
log::error!("OneCAD: the Odoo bridge is unavailable; \"{path}\" stays in the engine VFS (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 engine VFS and the OPFS autosave until the save
|
||||
// succeeds).
|
||||
true
|
||||
}
|
||||
|
||||
/// Mirror hook (tagged call site in `main.rs::files_tick`): CAD 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_imports(files: &Rc<Files>) {
|
||||
if !embedded() {
|
||||
return;
|
||||
}
|
||||
for (name, bytes, _) in files.incoming.borrow().iter() {
|
||||
if !is_asset_name(name) {
|
||||
continue;
|
||||
}
|
||||
if !MIRRORED.with(|m| m.borrow_mut().insert((name.clone(), bytes.len()))) {
|
||||
continue;
|
||||
}
|
||||
let params = Object::new();
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes.as_slice()).into());
|
||||
if !dispatch_event(IMPORT, next_id(), ¶ms) {
|
||||
log::warn!("OneCAD: could not mirror \"{name}\" into the Assets folder (bridge not ready)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_asset_name(name: &str) -> bool {
|
||||
std::path::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)))
|
||||
}
|
||||
|
||||
/// Install `window.onecadOdoo = { bootFile }` — the JS→Rust staging seam for
|
||||
/// deep links (`?document=<id>`): the glue inserts the record's bytes into
|
||||
/// the engine VFS, then opens the returned path via `window.solidcraft`.
|
||||
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, &"onecadOdoo".into(), &glue).is_ok() {
|
||||
BOOT.with(|b| b.set(true));
|
||||
}
|
||||
}
|
||||
+25
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Hlášení odešlete na GitHubu tlačítkem Submit new issue.",
|
||||
"A new configuration, a copy of the active one": "Nová konfigurace, kopie aktivní",
|
||||
"AI": "AI",
|
||||
"About SolidCraft": "O aplikaci SolidCraft",
|
||||
"About OneCAD": "O aplikaci OneCAD",
|
||||
"Active": "Aktivní",
|
||||
"Active rule": "Aktivní pravidlo",
|
||||
"Add": "Přidat",
|
||||
@@ -239,7 +239,7 @@
|
||||
"Sketch plane has no body-face attachment.": "Rovina náčrtu není připojena k ploše tělesa.",
|
||||
"Sketch points": "Body náčrtu",
|
||||
"Slot width": "Šířka drážky",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se zavřel dříve, než se tyto návrhy uložily. Lze otevřít jejich poslední automatické uložení.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD se zavřel dříve, než se tyto návrhy uložily. Lze otevřít jejich poslední automatické uložení.",
|
||||
"Speed": "Rychlost",
|
||||
"Stand it up": "Postavit",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Postavit: otočení o čtvrt otáčky kolem osy X jako jeden prvek Posunutí, který lze vrátit.",
|
||||
@@ -673,18 +673,18 @@
|
||||
"home.report_issue": "Nahlásit problém",
|
||||
"home.samples": "Ukázky",
|
||||
"home.whats_new": "Co je nového",
|
||||
"install.button_tip": "Nainstalovat SolidCraft jako aplikaci: otevře se ve vlastním okně a funguje i offline",
|
||||
"install.button_tip": "Nainstalovat OneCAD jako aplikaci: otevře se ve vlastním okně a funguje i offline",
|
||||
"install.dock_1": "Zvolte Soubor › Přidat do Docku.",
|
||||
"install.dock_2": "Klikněte na Přidat.",
|
||||
"install.dock_after": "SolidCraft se pak otevírá z Docku ve vlastním okně a funguje i offline.",
|
||||
"install.dock_after": "OneCAD se pak otevírá z Docku ve vlastním okně a funguje i offline.",
|
||||
"install.done": "Hotovo",
|
||||
"install.home_screen_1": "Klepněte na Sdílet (v nabídce …, pokud není na liště).",
|
||||
"install.home_screen_2": "Zvolte Přidat na plochu.",
|
||||
"install.home_screen_3": "Klepněte na Přidat.",
|
||||
"install.home_screen_after": "SolidCraft se pak otevírá ikonou na ploše bez lišt Safari a funguje i offline.",
|
||||
"install.home_screen_after": "OneCAD se pak otevírá ikonou na ploše bez lišt Safari a funguje i offline.",
|
||||
"install.menu": "Nainstalovat aplikaci…",
|
||||
"install.steps_intro": "Safari instaluje webové aplikace z vlastních nabídek:",
|
||||
"install.title": "Instalace SolidCraftu",
|
||||
"install.title": "Instalace OneCADu",
|
||||
"joint.from_bodies_hint": "Každé vybrané těleso se přesune do vlastní nové komponenty a zůstane na místě.",
|
||||
"joint.one_body": "Oba výběry jsou na {body}. Druhý vyberte na jiném dílu.",
|
||||
"joint.one_component": "Tyto díly jsou tělesy jedné komponenty: {bodies}. Kloub pohybuje jednou komponentou vůči druhé, takže každý díl potřebuje vlastní komponentu.",
|
||||
@@ -764,10 +764,10 @@
|
||||
"settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu. Dokud je zapnuto, pohled se překresluje nepřetržitě.",
|
||||
"settings.measure_axis_distances": "Zobrazit vzdálenosti X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Při otevřeném měření zobrazit kromě celkové vzdálenosti i složky ve směru os.",
|
||||
"settings.msaa_applies_on_restart": "Vyhlazování se projeví při příštím spuštění SolidCraftu",
|
||||
"settings.msaa_applies_on_restart": "Vyhlazování se projeví při příštím spuštění OneCADu",
|
||||
"settings.msaa_reset": "{setting}: {requested}× toto grafické zařízení nepodporuje; nyní je nastaveno {reset}× (podporováno: {supported}).",
|
||||
"settings.msaa_unsupported": "Toto grafické zařízení nepodporuje",
|
||||
"settings.reset_intro": "Tato nastavení na tomto počítači nefungují a bránila by spuštění SolidCraftu, proto byla změněna:",
|
||||
"settings.reset_intro": "Tato nastavení na tomto počítači nefungují a bránila by spuštění OneCADu, proto byla změněna:",
|
||||
"settings.reset_title": "Nastavení byla změněna",
|
||||
"settings.supersampling_note": "Vykresluje pohled ve vyšším rozlišení a poté jej zmenšuje: hladší hrany, čáry a mřížka na obrazovkách pod 4K. Stojí výkon GPU: asi 2,25× více pixelů při 1,5× a 4× při 2×.",
|
||||
"sketch.degrees_of_freedom": "Stupně volnosti: {count}",
|
||||
@@ -814,8 +814,8 @@
|
||||
"ui.agent_chat_attach_view": "Přiložit pohled",
|
||||
"ui.agent_chat_attach_view_tip": "Odeslat se zprávou obrázek aktuálního pohledu",
|
||||
"ui.agent_chat_auth_agent": "Přihlásit se přes {name}",
|
||||
"ui.agent_chat_auth_env": "Nastavte {vars} v proměnných prostředí a restartujte SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "Agent žádá o přihlášení. Přihlašujete se přímo u agenta; SolidCraft vaše heslo ani klíče nikdy nevidí.",
|
||||
"ui.agent_chat_auth_env": "Nastavte {vars} v proměnných prostředí a restartujte OneCAD.",
|
||||
"ui.agent_chat_auth_needed": "Agent žádá o přihlášení. Přihlašujete se přímo u agenta; OneCAD vaše heslo ani klíče nikdy nevidí.",
|
||||
"ui.agent_chat_auth_none": "Agent zde nenabízí žádný způsob přihlášení: přihlaste se podle jeho vlastní dokumentace.",
|
||||
"ui.agent_chat_auth_retry": "Přihlásil(a) jsem se: připojit znovu",
|
||||
"ui.agent_chat_auth_terminal": "Spusťte toto v terminálu a pak znovu stiskněte Připojit:",
|
||||
@@ -837,7 +837,7 @@
|
||||
"ui.agent_chat_edit": "Upravit",
|
||||
"ui.agent_chat_exited": "Agent se zastavil.",
|
||||
"ui.agent_chat_failed_start": "Agenta se nepodařilo spustit: {error}",
|
||||
"ui.agent_chat_intro": "Chatujte s kódovacím agentem, kterého již používáte, nebo s modelem na zvoleném endpointu. Pracuje na otevřeném návrhu pomocí nástrojů SolidCraftu: jeho změny vidíte průběžně a každou zprávu lze vrátit jedním krokem. Co smí dělat bez ptaní, nastavíte v Předvolby > AI.",
|
||||
"ui.agent_chat_intro": "Chatujte s kódovacím agentem, kterého již používáte, nebo s modelem na zvoleném endpointu. Pracuje na otevřeném návrhu pomocí nástrojů OneCADu: jeho změny vidíte průběžně a každou zprávu lze vrátit jedním krokem. Co smí dělat bez ptaní, nastavíte v Předvolby > AI.",
|
||||
"ui.agent_chat_key_in_env": "klíč nastaven",
|
||||
"ui.agent_chat_left": "Vlevo",
|
||||
"ui.agent_chat_limit": "Agent dosáhl limitu {limit} změn na jednu zprávu. Pošlete další zprávu, aby mohl pokračovat.",
|
||||
@@ -858,7 +858,7 @@
|
||||
"ui.agent_chat_placeholder": "Požádejte agenta o změnu návrhu…",
|
||||
"ui.agent_chat_placeholder_busy": "Zařadit zprávu, až agent skončí (Ctrl+Enter: odeslat hned)",
|
||||
"ui.agent_chat_privacy": "Nic se nikam neodesílá, jen agentovi, kterého spouštíte pod vlastním účtem.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft nic nikam neodesílá: agent, kterého spouštíte, komunikuje se svou službou pod vaším účtem. SolidCraft nikdy nevidí ani neukládá klíč či heslo.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD nic nikam neodesílá: agent, kterého spouštíte, komunikuje se svou službou pod vaším účtem. OneCAD nikdy nevidí ani neukládá klíč či heslo.",
|
||||
"ui.agent_chat_queue": "Zařadit",
|
||||
"ui.agent_chat_queue_full": "Čeká příliš mnoho zpráv: některou odeberte nebo počkejte na agenta.",
|
||||
"ui.agent_chat_queued": "Ve frontě",
|
||||
@@ -897,7 +897,7 @@
|
||||
"ui.ai_add_installed": "Nainstalovaný agent",
|
||||
"ui.ai_add_installed_note": "Vlastní položka pro nainstalovaného agenta, například s jinými argumenty. Nainstalovaní agenti se v seznamu zobrazují sami.",
|
||||
"ui.ai_added": "přidáno",
|
||||
"ui.ai_agent_mode_tip": "Vlastní režim agenta: řídí se jím jeho vlastní nástroje (shell, úpravy souborů). Nástroje SolidCraftu se řídí oprávněními v Předvolby > AI.",
|
||||
"ui.ai_agent_mode_tip": "Vlastní režim agenta: řídí se jím jeho vlastní nástroje (shell, úpravy souborů). Nástroje OneCADu se řídí oprávněními v Předvolby > AI.",
|
||||
"ui.ai_agents": "Agenti",
|
||||
"ui.ai_always_allow": "Vždy povolit",
|
||||
"ui.ai_always_allow_tip": "Od teď povolit {tool} tomuto agentovi bez ptaní",
|
||||
@@ -905,7 +905,7 @@
|
||||
"ui.ai_always_allowed_none": "Zatím není nic trvale povoleno. Zde se zobrazí odpovědi „Vždy povolit“ zadané v chatu.",
|
||||
"ui.ai_auto_allowed": "Automaticky povoleno: {title} ({why})",
|
||||
"ui.ai_auto_mode": "vaše nastavení oprávnění",
|
||||
"ui.ai_auto_ours": "SolidCraft své nástroje kontroluje sám",
|
||||
"ui.ai_auto_ours": "OneCAD své nástroje kontroluje sám",
|
||||
"ui.ai_auto_remembered": "zvolili jste Vždy povolit",
|
||||
"ui.ai_bad_url": "Zadejte URL začínající http:// nebo https://",
|
||||
"ui.ai_base_url": "Základní URL",
|
||||
@@ -919,7 +919,7 @@
|
||||
"ui.ai_key": "API klíč",
|
||||
"ui.ai_key_hint": "volitelné; uloženo v systémové klíčence",
|
||||
"ui.ai_key_needs_https": "Klíč se odesílá pouze přes HTTPS nebo na tento počítač.",
|
||||
"ui.ai_key_note": "Ukládá se v systémové klíčence, nikdy v nastavení SolidCraftu. Pro místní server nechte prázdné.",
|
||||
"ui.ai_key_note": "Ukládá se v systémové klíčence, nikdy v nastavení OneCADu. Pro místní server nechte prázdné.",
|
||||
"ui.ai_keychain_error": "Systémová klíčenka odmítla: {error}",
|
||||
"ui.ai_mode_all": "Povolit vše",
|
||||
"ui.ai_mode_all_note": "Nic se nejdřív neptá. Hlášení o pádech zůstávají nepřístupná a limity složek stále platí.",
|
||||
@@ -930,7 +930,7 @@
|
||||
"ui.ai_mode_edits_note": "Modelovací příkazy běží bez ptaní; ukládání, otevírání, export a další souborové či destruktivní akce se stále ptají.",
|
||||
"ui.ai_mode_global": "Podle nastavení výše",
|
||||
"ui.ai_model": "Model",
|
||||
"ui.ai_modes_scope": "Tato nastavení se týkají nástrojů SolidCraftu. Vlastní nástroje agenta (jeho shell, úpravy souborů) se řídí jeho vlastním režimem, který zvolíte v záhlaví panelu chatu, pokud agent režimy má.",
|
||||
"ui.ai_modes_scope": "Tato nastavení se týkají nástrojů OneCADu. Vlastní nástroje agenta (jeho shell, úpravy souborů) se řídí jeho vlastním režimem, který zvolíte v záhlaví panelu chatu, pokud agent režimy má.",
|
||||
"ui.ai_name": "Název",
|
||||
"ui.ai_need_command": "Zadejte příkaz, který spouští agenta",
|
||||
"ui.ai_need_model": "Zadejte název modelu",
|
||||
@@ -1445,31 +1445,31 @@
|
||||
"ui.zoom": "Přiblížení",
|
||||
"ui.zoom_to_cursor_tip": "Kolečko přibližuje k bodu pod kurzorem (vypnuto: ke středu pohledu)",
|
||||
"ui.zoom_to_the_cursor": "Přibližovat ke kurzoru",
|
||||
"update.available": "Je k dispozici SolidCraft {version}",
|
||||
"update.available": "Je k dispozici OneCAD {version}",
|
||||
"update.button": "Aktualizovat",
|
||||
"update.button_tip": "Je k dispozici SolidCraft {version}",
|
||||
"update.button_tip": "Je k dispozici OneCAD {version}",
|
||||
"update.button_version": "Aktualizovat na {version}",
|
||||
"update.check_automatically": "Automaticky kontrolovat aktualizace",
|
||||
"update.check_now": "Zkontrolovat nyní",
|
||||
"update.checking": "Kontrola…",
|
||||
"update.copy": "Kopírovat",
|
||||
"update.dmg_opened": "Obraz disku je otevřený a ověřený: přetáhněte SolidCraft do Aplikací místo staré kopie a poté SolidCraft restartujte.",
|
||||
"update.dmg_opened": "Obraz disku je otevřený a ověřený: přetáhněte OneCAD do Aplikací místo staré kopie a poté OneCAD restartujte.",
|
||||
"update.download": "Stáhnout",
|
||||
"update.download_open": "Stáhnout a otevřít",
|
||||
"update.downloading": "Stahování a ověřování… {done} z {total} MB",
|
||||
"update.failed": "Kontrola se nezdařila: {error}",
|
||||
"update.file": "Stáhne se {file}.",
|
||||
"update.install_failed": "Aktualizace se nenainstalovala: {error}. Nic se nezměnilo; tlačítkem Stáhnout získáte balíček.",
|
||||
"update.install_msi": "Nainstalovat (zavře SolidCraft)",
|
||||
"update.installed": "SolidCraft {version} je nainstalován. Pro použití restartujte.",
|
||||
"update.installer_started": "Instalátor se spustí po zavření SolidCraftu.",
|
||||
"update.install_msi": "Nainstalovat (zavře OneCAD)",
|
||||
"update.installed": "OneCAD {version} je nainstalován. Pro použití restartujte.",
|
||||
"update.installer_started": "Instalátor se spustí po zavření OneCADu.",
|
||||
"update.later": "Připomenout později",
|
||||
"update.no_notes": "Tato verze nemá poznámky.",
|
||||
"update.package_hint": "Tato kopie pochází z {kind}. Stáhněte nový balíček a nainstalujte jej pomocí:",
|
||||
"update.prefs_dev": "Toto je vývojová verze ({version}): aktualizace nekontroluje.",
|
||||
"update.prefs_flatpak": "Tuto kopii ({version}) aktualizuje Flatpak: spusťte {command} nebo použijte správce softwaru. SolidCraft nekontroluje.",
|
||||
"update.prefs_flatpak": "Tuto kopii ({version}) aktualizuje Flatpak: spusťte {command} nebo použijte správce softwaru. OneCAD nekontroluje.",
|
||||
"update.prefs_label": "Aktualizace",
|
||||
"update.prefs_note": "Toto je SolidCraft {version} ({kind}). Kontrola se dotáže GitHubu na nejnovější verzi a nic dalšího neodesílá.",
|
||||
"update.prefs_note": "Toto je OneCAD {version} ({kind}). Kontrola se dotáže GitHubu na nejnovější verzi a nic dalšího neodesílá.",
|
||||
"update.release_page": "Otevřít stránku vydání",
|
||||
"update.restart": "Restartovat nyní",
|
||||
"update.restart_later": "Později",
|
||||
@@ -1477,7 +1477,7 @@
|
||||
"update.skip": "Přeskočit tuto verzi",
|
||||
"update.system_hint": "Tato kopie je {kind}. Aktualizujte ji pomocí:",
|
||||
"update.title": "Je k dispozici aktualizace",
|
||||
"update.up_to_date": "SolidCraft je aktuální.",
|
||||
"update.up_to_date": "OneCAD je aktuální.",
|
||||
"update.update_now": "Aktualizovat nyní",
|
||||
"update.you_have": "Máte {version}."
|
||||
}
|
||||
+23
-23
@@ -486,18 +486,18 @@
|
||||
"home.report_issue": "Problem melden",
|
||||
"home.samples": "Beispiele",
|
||||
"home.whats_new": "Neuigkeiten",
|
||||
"install.button_tip": "SolidCraft als App installieren: eigenes Fenster, funktioniert auch offline",
|
||||
"install.button_tip": "OneCAD als App installieren: eigenes Fenster, funktioniert auch offline",
|
||||
"install.dock_1": "Wählen Sie Ablage › Zum Dock hinzufügen.",
|
||||
"install.dock_2": "Klicken Sie auf „Hinzufügen“.",
|
||||
"install.dock_after": "SolidCraft öffnet sich dann aus dem Dock in einem eigenen Fenster und funktioniert auch offline.",
|
||||
"install.dock_after": "OneCAD öffnet sich dann aus dem Dock in einem eigenen Fenster und funktioniert auch offline.",
|
||||
"install.done": "Fertig",
|
||||
"install.home_screen_1": "Tippen Sie auf „Teilen“ (im Menü …, falls es nicht in der Symbolleiste ist).",
|
||||
"install.home_screen_2": "Wählen Sie „Zum Home-Bildschirm“.",
|
||||
"install.home_screen_3": "Tippen Sie auf „Hinzufügen“.",
|
||||
"install.home_screen_after": "SolidCraft öffnet sich dann über sein Symbol auf dem Home-Bildschirm, ohne die Leisten von Safari, und funktioniert auch offline.",
|
||||
"install.home_screen_after": "OneCAD öffnet sich dann über sein Symbol auf dem Home-Bildschirm, ohne die Leisten von Safari, und funktioniert auch offline.",
|
||||
"install.menu": "App installieren…",
|
||||
"install.steps_intro": "Safari installiert Web-Apps über seine eigenen Menüs:",
|
||||
"install.title": "SolidCraft installieren",
|
||||
"install.title": "OneCAD installieren",
|
||||
"joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist.",
|
||||
"joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.",
|
||||
"joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.",
|
||||
@@ -572,10 +572,10 @@
|
||||
"settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht. Solange sie an ist, wird die Ansicht fortlaufend neu gezeichnet.",
|
||||
"settings.measure_axis_distances": "X/Y/Z-Maße anzeigen",
|
||||
"settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.",
|
||||
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
|
||||
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von OneCAD übernommen",
|
||||
"settings.msaa_reset": "{setting}: {requested}× wird von diesem Grafikgerät nicht unterstützt; jetzt {reset}× (unterstützt: {supported}).",
|
||||
"settings.msaa_unsupported": "Von diesem Grafikgerät nicht unterstützt",
|
||||
"settings.reset_intro": "Diese Einstellungen funktionieren auf diesem Computer nicht und würden den Start von SolidCraft verhindern. Sie wurden daher geändert:",
|
||||
"settings.reset_intro": "Diese Einstellungen funktionieren auf diesem Computer nicht und würden den Start von OneCAD verhindern. Sie wurden daher geändert:",
|
||||
"settings.reset_title": "Einstellungen wurden geändert",
|
||||
"settings.supersampling_note": "Zeichnet die Ansicht in höherer Auflösung und rechnet sie herunter: glattere Kanten, Linien und Raster auf Bildschirmen unter 4K. Kostet GPU-Zeit: etwa 2,25-mal so viele Pixel bei 1,5× und 4-mal bei 2×.",
|
||||
"sketch.degrees_of_freedom": "{count} Freiheitsgrade",
|
||||
@@ -618,8 +618,8 @@
|
||||
"ui.agent_chat_attach_view": "Ansicht anhängen",
|
||||
"ui.agent_chat_attach_view_tip": "Ein Bild der aktuellen Ansicht mit der Nachricht senden",
|
||||
"ui.agent_chat_auth_agent": "Mit {name} anmelden",
|
||||
"ui.agent_chat_auth_env": "Setzen Sie {vars} in Ihrer Umgebung und starten Sie SolidCraft neu.",
|
||||
"ui.agent_chat_auth_needed": "Der Agent bittet Sie, sich anzumelden. Sie melden sich beim Agenten selbst an; SolidCraft sieht Ihr Passwort oder Ihre Schlüssel nie.",
|
||||
"ui.agent_chat_auth_env": "Setzen Sie {vars} in Ihrer Umgebung und starten Sie OneCAD neu.",
|
||||
"ui.agent_chat_auth_needed": "Der Agent bittet Sie, sich anzumelden. Sie melden sich beim Agenten selbst an; OneCAD sieht Ihr Passwort oder Ihre Schlüssel nie.",
|
||||
"ui.agent_chat_auth_none": "Der Agent bietet hier keine Anmeldung an: Melden Sie sich so an, wie es seine Dokumentation beschreibt.",
|
||||
"ui.agent_chat_auth_retry": "Ich bin angemeldet: erneut verbinden",
|
||||
"ui.agent_chat_auth_terminal": "Führen Sie dies in einem Terminal aus und klicken Sie dann erneut auf Verbinden:",
|
||||
@@ -640,7 +640,7 @@
|
||||
"ui.agent_chat_dock_right": "Rechts andocken",
|
||||
"ui.agent_chat_exited": "Der Agent wurde beendet.",
|
||||
"ui.agent_chat_failed_start": "Der Agent konnte nicht starten: {error}",
|
||||
"ui.agent_chat_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von SolidCraft am geöffneten Design: Seine Änderungen erscheinen sofort und lassen sich pro Nachricht in einem Schritt rückgängig machen. Was er ohne Nachfrage darf, legen Sie unter Einstellungen > KI fest.",
|
||||
"ui.agent_chat_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von OneCAD am geöffneten Design: Seine Änderungen erscheinen sofort und lassen sich pro Nachricht in einem Schritt rückgängig machen. Was er ohne Nachfrage darf, legen Sie unter Einstellungen > KI fest.",
|
||||
"ui.agent_chat_key_in_env": "Schlüssel gesetzt",
|
||||
"ui.agent_chat_left": "Links",
|
||||
"ui.agent_chat_limit": "Der Agent hat die Grenze von {limit} Änderungen für eine Nachricht erreicht. Senden Sie eine weitere Nachricht, damit er weitermacht.",
|
||||
@@ -656,7 +656,7 @@
|
||||
"ui.agent_chat_pick": "Agent",
|
||||
"ui.agent_chat_placeholder": "Bitten Sie den Agenten, das Design zu ändern…",
|
||||
"ui.agent_chat_privacy": "Nichts geht irgendwohin außer an den Agenten, den Sie unter Ihrem eigenen Konto ausführen.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft sendet nichts irgendwohin: Der Agent, den Sie starten, spricht unter Ihrem eigenen Konto mit seinem eigenen Dienst. SolidCraft sieht und speichert nie einen Schlüssel oder ein Passwort.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD sendet nichts irgendwohin: Der Agent, den Sie starten, spricht unter Ihrem eigenen Konto mit seinem eigenen Dienst. OneCAD sieht und speichert nie einen Schlüssel oder ein Passwort.",
|
||||
"ui.agent_chat_refresh": "Aktualisieren",
|
||||
"ui.agent_chat_refused": "Für den Agenten nicht erlaubt: {reason}",
|
||||
"ui.agent_chat_right": "Rechts",
|
||||
@@ -686,7 +686,7 @@
|
||||
"ui.ai_add_installed": "Installierter Agent",
|
||||
"ui.ai_add_installed_note": "Ein eigener Eintrag für einen installierten Agenten, zum Beispiel mit anderen Argumenten. Installierte Agenten erscheinen von selbst.",
|
||||
"ui.ai_added": "hinzugefügt",
|
||||
"ui.ai_agent_mode_tip": "Der eigene Modus des Agenten: Seine eigenen Werkzeuge (Shell, Dateiänderungen) folgen ihm. Die Werkzeuge von SolidCraft folgen den Berechtigungen unter Einstellungen > KI.",
|
||||
"ui.ai_agent_mode_tip": "Der eigene Modus des Agenten: Seine eigenen Werkzeuge (Shell, Dateiänderungen) folgen ihm. Die Werkzeuge von OneCAD folgen den Berechtigungen unter Einstellungen > KI.",
|
||||
"ui.ai_agents": "Agenten",
|
||||
"ui.ai_always_allow": "Immer erlauben",
|
||||
"ui.ai_always_allow_tip": "{tool} für diesen Agenten ab jetzt ohne Nachfrage erlauben",
|
||||
@@ -694,7 +694,7 @@
|
||||
"ui.ai_always_allowed_none": "Noch nichts ist immer erlaubt. Antworten „Immer erlauben“ im Chat erscheinen hier.",
|
||||
"ui.ai_auto_allowed": "Automatisch erlaubt: {title} ({why})",
|
||||
"ui.ai_auto_mode": "Ihre Berechtigungseinstellung",
|
||||
"ui.ai_auto_ours": "SolidCraft prüft seine eigenen Werkzeuge selbst",
|
||||
"ui.ai_auto_ours": "OneCAD prüft seine eigenen Werkzeuge selbst",
|
||||
"ui.ai_auto_remembered": "Sie haben „Immer erlauben“ gewählt",
|
||||
"ui.ai_bad_url": "Geben Sie eine URL ein, die mit http:// oder https:// beginnt",
|
||||
"ui.ai_base_url": "Basis-URL",
|
||||
@@ -708,7 +708,7 @@
|
||||
"ui.ai_key": "API-Schlüssel",
|
||||
"ui.ai_key_hint": "optional; im Schlüsselbund des Systems gespeichert",
|
||||
"ui.ai_key_needs_https": "Ein Schlüssel wird nur über HTTPS oder an diesen Computer gesendet.",
|
||||
"ui.ai_key_note": "Im Schlüsselbund des Systems gespeichert, nie in den Einstellungen von SolidCraft. Für einen lokalen Server leer lassen.",
|
||||
"ui.ai_key_note": "Im Schlüsselbund des Systems gespeichert, nie in den Einstellungen von OneCAD. Für einen lokalen Server leer lassen.",
|
||||
"ui.ai_keychain_error": "Der Schlüsselbund des Systems hat abgelehnt: {error}",
|
||||
"ui.ai_mode_all": "Alles erlauben",
|
||||
"ui.ai_mode_all_note": "Nichts fragt vorher. Absturzberichte bleiben tabu, und die Ordnergrenzen gelten weiter.",
|
||||
@@ -719,7 +719,7 @@
|
||||
"ui.ai_mode_edits_note": "Modellierbefehle laufen ohne Nachfrage; Speichern, Öffnen, Exportieren und andere Datei- oder destruktive Aktionen fragen weiterhin.",
|
||||
"ui.ai_mode_global": "Wie oben eingestellt",
|
||||
"ui.ai_model": "Modell",
|
||||
"ui.ai_modes_scope": "Diese Einstellungen gelten für die Werkzeuge von SolidCraft. Die eigenen Werkzeuge eines Agenten (seine Shell, seine Dateiänderungen) folgen seinem eigenen Modus, den Sie im Kopf des Chat-Panels wählen, wenn der Agent Modi hat.",
|
||||
"ui.ai_modes_scope": "Diese Einstellungen gelten für die Werkzeuge von OneCAD. Die eigenen Werkzeuge eines Agenten (seine Shell, seine Dateiänderungen) folgen seinem eigenen Modus, den Sie im Kopf des Chat-Panels wählen, wenn der Agent Modi hat.",
|
||||
"ui.ai_name": "Name",
|
||||
"ui.ai_need_command": "Geben Sie den Befehl ein, der den Agenten startet",
|
||||
"ui.ai_need_model": "Geben Sie den Modellnamen ein",
|
||||
@@ -1206,31 +1206,31 @@
|
||||
"ui.y_distance": "Y-Abstand",
|
||||
"ui.zoom": "Zoomen",
|
||||
"ui.zoom_to_the_cursor": "Zum Cursor zoomen",
|
||||
"update.available": "SolidCraft {version} ist verfügbar",
|
||||
"update.available": "OneCAD {version} ist verfügbar",
|
||||
"update.button": "Update",
|
||||
"update.button_tip": "SolidCraft {version} ist verfügbar",
|
||||
"update.button_tip": "OneCAD {version} ist verfügbar",
|
||||
"update.button_version": "Auf {version} aktualisieren",
|
||||
"update.check_automatically": "Automatisch nach Updates suchen",
|
||||
"update.check_now": "Jetzt suchen",
|
||||
"update.checking": "Suche läuft…",
|
||||
"update.copy": "Kopieren",
|
||||
"update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: SolidCraft in „Programme“ ziehen, die alte Version ersetzen und SolidCraft neu starten.",
|
||||
"update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: OneCAD in „Programme“ ziehen, die alte Version ersetzen und OneCAD neu starten.",
|
||||
"update.download": "Herunterladen",
|
||||
"update.download_open": "Herunterladen und öffnen",
|
||||
"update.downloading": "Herunterladen und prüfen… {done} von {total} MB",
|
||||
"update.failed": "Suche fehlgeschlagen: {error}",
|
||||
"update.file": "Herunterladen holt {file}.",
|
||||
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket.",
|
||||
"update.install_msi": "Installieren (schließt SolidCraft)",
|
||||
"update.installed": "SolidCraft {version} ist installiert. Zum Verwenden neu starten.",
|
||||
"update.installer_started": "Das Installationsprogramm startet, wenn SolidCraft geschlossen wird.",
|
||||
"update.install_msi": "Installieren (schließt OneCAD)",
|
||||
"update.installed": "OneCAD {version} ist installiert. Zum Verwenden neu starten.",
|
||||
"update.installer_started": "Das Installationsprogramm startet, wenn OneCAD geschlossen wird.",
|
||||
"update.later": "Später erinnern",
|
||||
"update.no_notes": "Zu diesem Release gibt es keine Hinweise.",
|
||||
"update.package_hint": "Diese Installation stammt aus einem {kind}. Neues Paket herunterladen und installieren mit:",
|
||||
"update.prefs_dev": "Dies ist ein Entwicklungs-Build ({version}): er sucht nicht nach Updates.",
|
||||
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. SolidCraft sucht nicht selbst.",
|
||||
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. OneCAD sucht nicht selbst.",
|
||||
"update.prefs_label": "Updates",
|
||||
"update.prefs_note": "Dies ist SolidCraft {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.",
|
||||
"update.prefs_note": "Dies ist OneCAD {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.",
|
||||
"update.release_page": "Release-Seite öffnen",
|
||||
"update.restart": "Jetzt neu starten",
|
||||
"update.restart_later": "Später",
|
||||
@@ -1238,7 +1238,7 @@
|
||||
"update.skip": "Diese Version überspringen",
|
||||
"update.system_hint": "Diese Installation ist ein {kind}. Aktualisieren mit:",
|
||||
"update.title": "Update verfügbar",
|
||||
"update.up_to_date": "SolidCraft ist aktuell.",
|
||||
"update.up_to_date": "OneCAD ist aktuell.",
|
||||
"update.update_now": "Jetzt aktualisieren",
|
||||
"update.you_have": "Installiert ist {version}."
|
||||
}
|
||||
+28
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Click Submit new issue on GitHub to send your report.",
|
||||
"A new configuration, a copy of the active one": "A new configuration, a copy of the active one",
|
||||
"AI": "AI",
|
||||
"About SolidCraft": "About SolidCraft",
|
||||
"About OneCAD": "About OneCAD",
|
||||
"Acceleration": "Acceleration",
|
||||
"Active": "Active",
|
||||
"Active rule": "Active rule",
|
||||
@@ -254,7 +254,7 @@
|
||||
"Sketch plane has no body-face attachment.": "Sketch plane has no body-face attachment.",
|
||||
"Sketch points": "Sketch points",
|
||||
"Slot width": "Slot width",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft closed before these designs were saved. Their last autosave can be opened.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD closed before these designs were saved. Their last autosave can be opened.",
|
||||
"Speed": "Speed",
|
||||
"Stand it up": "Stand it up",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Stand it up: a quarter turn about X, as one Move feature you can undo.",
|
||||
@@ -697,18 +697,18 @@
|
||||
"home.report_issue": "Report an Issue",
|
||||
"home.samples": "Samples",
|
||||
"home.whats_new": "What's new",
|
||||
"install.button_tip": "Install SolidCraft as an app: it opens in its own window and works offline",
|
||||
"install.button_tip": "Install OneCAD as an app: it opens in its own window and works offline",
|
||||
"install.dock_1": "Choose File › Add to Dock.",
|
||||
"install.dock_2": "Click Add.",
|
||||
"install.dock_after": "SolidCraft then opens in its own window from the Dock, and works offline.",
|
||||
"install.dock_after": "OneCAD then opens in its own window from the Dock, and works offline.",
|
||||
"install.done": "Done",
|
||||
"install.home_screen_1": "Tap Share (in the … menu if it isn't on the toolbar).",
|
||||
"install.home_screen_2": "Choose Add to Home Screen.",
|
||||
"install.home_screen_3": "Tap Add.",
|
||||
"install.home_screen_after": "SolidCraft then opens from its icon on the Home Screen, without Safari's toolbars, and works offline.",
|
||||
"install.home_screen_after": "OneCAD then opens from its icon on the Home Screen, without Safari's toolbars, and works offline.",
|
||||
"install.menu": "Install app…",
|
||||
"install.steps_intro": "Safari installs web apps from its own menus:",
|
||||
"install.title": "Install SolidCraft",
|
||||
"install.title": "Install OneCAD",
|
||||
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.",
|
||||
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
|
||||
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
|
||||
@@ -792,10 +792,10 @@
|
||||
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner. While it is on, the view redraws continuously.",
|
||||
"settings.measure_axis_distances": "Show X/Y/Z distances",
|
||||
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
|
||||
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
|
||||
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time OneCAD starts",
|
||||
"settings.msaa_reset": "{setting}: {requested}× isn't supported by this graphics device; it is now {reset}× (supported: {supported}).",
|
||||
"settings.msaa_unsupported": "Not supported by this graphics device",
|
||||
"settings.reset_intro": "These settings don't work on this computer and would keep SolidCraft from starting, so they were changed:",
|
||||
"settings.reset_intro": "These settings don't work on this computer and would keep OneCAD from starting, so they were changed:",
|
||||
"settings.reset_title": "Settings were changed",
|
||||
"settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.",
|
||||
"sketch.degrees_of_freedom": "{count} degrees of freedom",
|
||||
@@ -848,8 +848,8 @@
|
||||
"ui.agent_chat_attach_view": "Attach view",
|
||||
"ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message",
|
||||
"ui.agent_chat_auth_agent": "Sign in with {name}",
|
||||
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.",
|
||||
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart OneCAD.",
|
||||
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; OneCAD never sees your password or keys.",
|
||||
"ui.agent_chat_auth_none": "The agent offers no sign-in method here: sign in the way its own documentation describes.",
|
||||
"ui.agent_chat_auth_retry": "I've signed in: connect again",
|
||||
"ui.agent_chat_auth_terminal": "Run this in a terminal, then press Connect again:",
|
||||
@@ -866,12 +866,13 @@
|
||||
"ui.agent_chat_deny": "Deny",
|
||||
"ui.agent_chat_desktop_only": "The agent chat needs the desktop app.",
|
||||
"ui.agent_chat_detecting": "Looking for agents…",
|
||||
"ui.agent_chat_dock": "Dock in the side panel",
|
||||
"ui.agent_chat_dock_left": "Dock on the left",
|
||||
"ui.agent_chat_dock_right": "Dock on the right",
|
||||
"ui.agent_chat_edit": "Edit",
|
||||
"ui.agent_chat_exited": "The agent stopped.",
|
||||
"ui.agent_chat_failed_start": "The agent could not start: {error}",
|
||||
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
|
||||
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through OneCAD's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
|
||||
"ui.agent_chat_key_in_env": "key set",
|
||||
"ui.agent_chat_left": "Left",
|
||||
"ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.",
|
||||
@@ -887,12 +888,14 @@
|
||||
"ui.agent_chat_none_found_short": "No agent found",
|
||||
"ui.agent_chat_not_installed": "Not installed",
|
||||
"ui.agent_chat_not_signed_in": "not signed in",
|
||||
"ui.agent_chat_own_window": "Own window",
|
||||
"ui.agent_chat_permission": "The agent asks to run: {title}",
|
||||
"ui.agent_chat_pick": "Agent",
|
||||
"ui.agent_chat_placeholder": "Ask the agent to change the design…",
|
||||
"ui.agent_chat_placeholder_busy": "Queue a message for when the agent is done (Ctrl+Enter: send now)",
|
||||
"ui.agent_chat_pop_out": "Open in its own window, which you can move to another screen",
|
||||
"ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft sends nothing anywhere: the agent you run talks to its own service under your own account. SolidCraft never sees or stores a key or password.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD sends nothing anywhere: the agent you run talks to its own service under your own account. OneCAD never sees or stores a key or password.",
|
||||
"ui.agent_chat_queue": "Queue",
|
||||
"ui.agent_chat_queue_full": "Too many messages are waiting: remove one or wait for the agent.",
|
||||
"ui.agent_chat_queued": "Queued",
|
||||
@@ -931,7 +934,7 @@
|
||||
"ui.ai_add_installed": "Installed agent",
|
||||
"ui.ai_add_installed_note": "An entry of your own for an installed agent, for example with other arguments. Installed agents are listed by themselves.",
|
||||
"ui.ai_added": "added",
|
||||
"ui.ai_agent_mode_tip": "The agent's own mode: its own tools (shell, file edits) follow it. SolidCraft's tools follow the permissions in Preferences > AI.",
|
||||
"ui.ai_agent_mode_tip": "The agent's own mode: its own tools (shell, file edits) follow it. OneCAD's tools follow the permissions in Preferences > AI.",
|
||||
"ui.ai_agents": "Agents",
|
||||
"ui.ai_always_allow": "Always allow",
|
||||
"ui.ai_always_allow_tip": "Allow {tool} for this agent from now on, without asking",
|
||||
@@ -939,7 +942,7 @@
|
||||
"ui.ai_always_allowed_none": "Nothing is always allowed yet. \"Always allow\" answers you give in the chat are listed here.",
|
||||
"ui.ai_auto_allowed": "Auto-allowed: {title} ({why})",
|
||||
"ui.ai_auto_mode": "your permission setting",
|
||||
"ui.ai_auto_ours": "SolidCraft checks its own tools itself",
|
||||
"ui.ai_auto_ours": "OneCAD checks its own tools itself",
|
||||
"ui.ai_auto_remembered": "you chose Always allow",
|
||||
"ui.ai_bad_url": "Enter a URL that starts with http:// or https://",
|
||||
"ui.ai_base_url": "Base URL",
|
||||
@@ -953,7 +956,7 @@
|
||||
"ui.ai_key": "API key",
|
||||
"ui.ai_key_hint": "optional; kept in the system keychain",
|
||||
"ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.",
|
||||
"ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.",
|
||||
"ui.ai_key_note": "Stored in the system keychain, never in OneCAD's settings. Leave it empty for a local server.",
|
||||
"ui.ai_keychain_error": "The system keychain refused: {error}",
|
||||
"ui.ai_mode_all": "Allow everything",
|
||||
"ui.ai_mode_all_note": "Nothing asks first. Crash reports stay off limits and the folder limits still apply.",
|
||||
@@ -964,7 +967,7 @@
|
||||
"ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.",
|
||||
"ui.ai_mode_global": "As set above",
|
||||
"ui.ai_model": "Model",
|
||||
"ui.ai_modes_scope": "These settings cover SolidCraft's tools. An agent's own tools (its shell, its file edits) follow the agent's own mode, which you pick in the chat panel's header when the agent has modes.",
|
||||
"ui.ai_modes_scope": "These settings cover OneCAD's tools. An agent's own tools (its shell, its file edits) follow the agent's own mode, which you pick in the chat panel's header when the agent has modes.",
|
||||
"ui.ai_name": "Name",
|
||||
"ui.ai_need_command": "Enter the command that starts the agent",
|
||||
"ui.ai_need_model": "Enter the model name",
|
||||
@@ -1494,31 +1497,31 @@
|
||||
"ui.zoom": "Zoom",
|
||||
"ui.zoom_to_cursor_tip": "The wheel zooms toward the point under the cursor (off: toward the view's centre)",
|
||||
"ui.zoom_to_the_cursor": "Zoom to the cursor",
|
||||
"update.available": "SolidCraft {version} is available",
|
||||
"update.available": "OneCAD {version} is available",
|
||||
"update.button": "Update",
|
||||
"update.button_tip": "SolidCraft {version} is available",
|
||||
"update.button_tip": "OneCAD {version} is available",
|
||||
"update.button_version": "Update to {version}",
|
||||
"update.check_automatically": "Check for updates automatically",
|
||||
"update.check_now": "Check now",
|
||||
"update.checking": "Checking…",
|
||||
"update.copy": "Copy",
|
||||
"update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.",
|
||||
"update.dmg_opened": "The disk image is open and verified: drag OneCAD to Applications, replacing the old copy, then restart OneCAD.",
|
||||
"update.download": "Download",
|
||||
"update.download_open": "Download and open",
|
||||
"update.downloading": "Downloading and verifying… {done} of {total} MB",
|
||||
"update.failed": "Couldn't check: {error}",
|
||||
"update.file": "Download gets {file}.",
|
||||
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead.",
|
||||
"update.install_msi": "Install (closes SolidCraft)",
|
||||
"update.installed": "SolidCraft {version} is installed. Restart to use it.",
|
||||
"update.installer_started": "The installer starts when SolidCraft closes.",
|
||||
"update.install_msi": "Install (closes OneCAD)",
|
||||
"update.installed": "OneCAD {version} is installed. Restart to use it.",
|
||||
"update.installer_started": "The installer starts when OneCAD closes.",
|
||||
"update.later": "Remind me later",
|
||||
"update.no_notes": "This release has no notes.",
|
||||
"update.package_hint": "This copy came from a {kind}. Download the new package, then install it with:",
|
||||
"update.prefs_dev": "This is a development build ({version}): it doesn't check for updates.",
|
||||
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. SolidCraft doesn't check.",
|
||||
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. OneCAD doesn't check.",
|
||||
"update.prefs_label": "Updates",
|
||||
"update.prefs_note": "This is SolidCraft {version} ({kind}). A check asks GitHub for the latest release and sends nothing else.",
|
||||
"update.prefs_note": "This is OneCAD {version} ({kind}). A check asks GitHub for the latest release and sends nothing else.",
|
||||
"update.release_page": "Open release page",
|
||||
"update.restart": "Restart now",
|
||||
"update.restart_later": "Later",
|
||||
@@ -1526,7 +1529,7 @@
|
||||
"update.skip": "Skip this version",
|
||||
"update.system_hint": "This copy is a {kind}. Update it with:",
|
||||
"update.title": "Update available",
|
||||
"update.up_to_date": "SolidCraft is up to date.",
|
||||
"update.up_to_date": "OneCAD is up to date.",
|
||||
"update.update_now": "Update now",
|
||||
"update.you_have": "You have {version}."
|
||||
}
|
||||
+25
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Haz clic en «Submit new issue» en GitHub para enviar el informe.",
|
||||
"A new configuration, a copy of the active one": "Una configuración nueva, copia de la activa",
|
||||
"AI": "AI",
|
||||
"About SolidCraft": "Acerca de SolidCraft",
|
||||
"About OneCAD": "Acerca de OneCAD",
|
||||
"Acceleration": "Aceleración",
|
||||
"Active": "Activo",
|
||||
"Active rule": "Regla activa",
|
||||
@@ -252,7 +252,7 @@
|
||||
"Sketch plane has no body-face attachment.": "El plano del boceto no está vinculado a ninguna cara del cuerpo.",
|
||||
"Sketch points": "Puntos del boceto",
|
||||
"Slot width": "Anchura de la ranura",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se cerró antes de guardar estos diseños. Puedes abrir su último guardado automático.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD se cerró antes de guardar estos diseños. Puedes abrir su último guardado automático.",
|
||||
"Speed": "Velocidad",
|
||||
"Stand it up": "Poner en vertical",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Poner en vertical: un cuarto de vuelta alrededor del eje X, como una única operación Mover que puedes deshacer.",
|
||||
@@ -693,18 +693,18 @@
|
||||
"home.report_issue": "Notificar un problema",
|
||||
"home.samples": "Ejemplos",
|
||||
"home.whats_new": "Novedades",
|
||||
"install.button_tip": "Instalar SolidCraft como aplicación: se abre en una ventana propia y funciona sin conexión",
|
||||
"install.button_tip": "Instalar OneCAD como aplicación: se abre en una ventana propia y funciona sin conexión",
|
||||
"install.dock_1": "Elige Archivo › Añadir al Dock.",
|
||||
"install.dock_2": "Haz clic en Añadir.",
|
||||
"install.dock_after": "A partir de entonces, SolidCraft se abrirá desde el Dock en su propia ventana y funcionará sin conexión.",
|
||||
"install.dock_after": "A partir de entonces, OneCAD se abrirá desde el Dock en su propia ventana y funcionará sin conexión.",
|
||||
"install.done": "Listo",
|
||||
"install.home_screen_1": "Toca Compartir (en el menú … si no aparece en la barra de herramientas).",
|
||||
"install.home_screen_2": "Elige Añadir a la pantalla de inicio.",
|
||||
"install.home_screen_3": "Toca Añadir.",
|
||||
"install.home_screen_after": "SolidCraft se abrirá desde su icono en la pantalla de inicio, sin las barras de Safari, y funcionará sin conexión.",
|
||||
"install.home_screen_after": "OneCAD se abrirá desde su icono en la pantalla de inicio, sin las barras de Safari, y funcionará sin conexión.",
|
||||
"install.menu": "Instalar aplicación…",
|
||||
"install.steps_intro": "Safari instala aplicaciones web desde sus propios menús:",
|
||||
"install.title": "Instalar SolidCraft",
|
||||
"install.title": "Instalar OneCAD",
|
||||
"joint.from_bodies_hint": "Cada cuerpo seleccionado pasa a un componente nuevo propio y conserva su posición.",
|
||||
"joint.one_body": "Las dos selecciones pertenecen a {body}. Selecciona la segunda en otra pieza.",
|
||||
"joint.one_component": "Estas piezas son cuerpos de un mismo componente: {bodies}. Una unión mueve un componente respecto a otro, por lo que cada pieza debe tener su propio componente.",
|
||||
@@ -788,10 +788,10 @@
|
||||
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.",
|
||||
"settings.measure_axis_distances": "Mostrar distancias X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.",
|
||||
"settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie SolidCraft",
|
||||
"settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie OneCAD",
|
||||
"settings.msaa_reset": "{setting}: este dispositivo gráfico no admite {requested}×; se ha establecido {reset}× (admitidos: {supported}).",
|
||||
"settings.msaa_unsupported": "No compatible con este dispositivo gráfico",
|
||||
"settings.reset_intro": "Estos ajustes no funcionan en este ordenador e impedirían iniciar SolidCraft, por lo que se han cambiado:",
|
||||
"settings.reset_intro": "Estos ajustes no funcionan en este ordenador e impedirían iniciar OneCAD, por lo que se han cambiado:",
|
||||
"settings.reset_title": "Se han cambiado los ajustes",
|
||||
"settings.supersampling_note": "Dibuja la vista a mayor resolución y después la reduce: bordes, líneas y cuadrícula más suaves en pantallas inferiores a 4K. Consume recursos de GPU: unos 2,25× píxeles a 1,5× y 4× a 2×.",
|
||||
"sketch.degrees_of_freedom": "{count} grados de libertad",
|
||||
@@ -842,8 +842,8 @@
|
||||
"ui.agent_chat_attach_view": "Adjuntar vista",
|
||||
"ui.agent_chat_attach_view_tip": "Enviar una imagen de la vista actual con el mensaje",
|
||||
"ui.agent_chat_auth_agent": "Iniciar sesión con {name}",
|
||||
"ui.agent_chat_auth_env": "Configura {vars} en las variables de entorno y reinicia SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "El agente te pide que inicies sesión. El inicio de sesión se realiza con el propio agente; SolidCraft nunca ve tu contraseña ni tus claves.",
|
||||
"ui.agent_chat_auth_env": "Configura {vars} en las variables de entorno y reinicia OneCAD.",
|
||||
"ui.agent_chat_auth_needed": "El agente te pide que inicies sesión. El inicio de sesión se realiza con el propio agente; OneCAD nunca ve tu contraseña ni tus claves.",
|
||||
"ui.agent_chat_auth_none": "El agente no ofrece aquí ningún método de inicio de sesión: sigue las instrucciones de su documentación.",
|
||||
"ui.agent_chat_auth_retry": "Ya he iniciado sesión: volver a conectar",
|
||||
"ui.agent_chat_auth_terminal": "Ejecuta esto en una terminal y vuelve a pulsar Conectar:",
|
||||
@@ -865,7 +865,7 @@
|
||||
"ui.agent_chat_edit": "Editar",
|
||||
"ui.agent_chat_exited": "El agente se ha detenido.",
|
||||
"ui.agent_chat_failed_start": "No se ha podido iniciar el agente: {error}",
|
||||
"ui.agent_chat_intro": "Habla con un agente de programación que ya utilices o con un servicio de modelos que elijas. Trabaja sobre el diseño abierto mediante las herramientas de SolidCraft: sus cambios se muestran al realizarlos y pueden deshacerse de una vez por mensaje. Lo que puede hacer sin preguntar se configura en Preferencias > IA.",
|
||||
"ui.agent_chat_intro": "Habla con un agente de programación que ya utilices o con un servicio de modelos que elijas. Trabaja sobre el diseño abierto mediante las herramientas de OneCAD: sus cambios se muestran al realizarlos y pueden deshacerse de una vez por mensaje. Lo que puede hacer sin preguntar se configura en Preferencias > IA.",
|
||||
"ui.agent_chat_key_in_env": "clave configurada",
|
||||
"ui.agent_chat_left": "Izquierda",
|
||||
"ui.agent_chat_limit": "El agente ha alcanzado el límite de {limit} cambios por mensaje. Envía otro mensaje para que continúe.",
|
||||
@@ -886,7 +886,7 @@
|
||||
"ui.agent_chat_placeholder": "Pide al agente que modifique el diseño…",
|
||||
"ui.agent_chat_placeholder_busy": "Pon un mensaje en cola para cuando termine el agente (Ctrl+Intro: enviar ahora)",
|
||||
"ui.agent_chat_privacy": "Solo se envían datos al agente que ejecutes, con tu propia cuenta.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft no envía nada a otros destinos: el agente que ejecutas se comunica con su propio servicio utilizando tu cuenta. SolidCraft nunca ve ni almacena claves o contraseñas.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD no envía nada a otros destinos: el agente que ejecutas se comunica con su propio servicio utilizando tu cuenta. OneCAD nunca ve ni almacena claves o contraseñas.",
|
||||
"ui.agent_chat_queue": "Poner en cola",
|
||||
"ui.agent_chat_queue_full": "Hay demasiados mensajes en espera: elimina uno o espera a que termine el agente.",
|
||||
"ui.agent_chat_queued": "En cola",
|
||||
@@ -925,7 +925,7 @@
|
||||
"ui.ai_add_installed": "Agente instalado",
|
||||
"ui.ai_add_installed_note": "Una entrada personalizada para un agente instalado, por ejemplo, con otros argumentos. Los agentes instalados también aparecen por separado.",
|
||||
"ui.ai_added": "añadido",
|
||||
"ui.ai_agent_mode_tip": "Modo propio del agente: se aplica a sus herramientas (terminal, edición de archivos). Las herramientas de SolidCraft siguen los permisos de Preferencias > IA.",
|
||||
"ui.ai_agent_mode_tip": "Modo propio del agente: se aplica a sus herramientas (terminal, edición de archivos). Las herramientas de OneCAD siguen los permisos de Preferencias > IA.",
|
||||
"ui.ai_agents": "Agentes",
|
||||
"ui.ai_always_allow": "Permitir siempre",
|
||||
"ui.ai_always_allow_tip": "Permitir {tool} a este agente a partir de ahora, sin preguntar",
|
||||
@@ -933,7 +933,7 @@
|
||||
"ui.ai_always_allowed_none": "Todavía no hay permisos permanentes. Aquí se muestran las respuestas «Permitir siempre» que hayas dado en el chat.",
|
||||
"ui.ai_auto_allowed": "Permitido automáticamente: {title} ({why})",
|
||||
"ui.ai_auto_mode": "tu configuración de permisos",
|
||||
"ui.ai_auto_ours": "SolidCraft comprueba sus propias herramientas",
|
||||
"ui.ai_auto_ours": "OneCAD comprueba sus propias herramientas",
|
||||
"ui.ai_auto_remembered": "elegiste Permitir siempre",
|
||||
"ui.ai_bad_url": "Introduce una URL que empiece por http:// o https://",
|
||||
"ui.ai_base_url": "URL base",
|
||||
@@ -947,7 +947,7 @@
|
||||
"ui.ai_key": "Clave de API",
|
||||
"ui.ai_key_hint": "opcional; se guarda en el llavero del sistema",
|
||||
"ui.ai_key_needs_https": "La clave solo se envía mediante HTTPS o a este ordenador.",
|
||||
"ui.ai_key_note": "Se guarda en el llavero del sistema, nunca en los ajustes de SolidCraft. Déjalo vacío para un servidor local.",
|
||||
"ui.ai_key_note": "Se guarda en el llavero del sistema, nunca en los ajustes de OneCAD. Déjalo vacío para un servidor local.",
|
||||
"ui.ai_keychain_error": "El llavero del sistema ha rechazado la operación: {error}",
|
||||
"ui.ai_mode_all": "Permitir todo",
|
||||
"ui.ai_mode_all_note": "No se pide permiso previamente. Los informes de fallos siguen estando restringidos y se mantienen los límites de carpetas.",
|
||||
@@ -958,7 +958,7 @@
|
||||
"ui.ai_mode_edits_note": "Los comandos de modelado se ejecutan sin preguntar; guardar, abrir, exportar y otras acciones sobre archivos o destructivas siguen requiriendo permiso.",
|
||||
"ui.ai_mode_global": "Según la configuración anterior",
|
||||
"ui.ai_model": "Modelo",
|
||||
"ui.ai_modes_scope": "Estos ajustes se aplican a las herramientas de SolidCraft. Las herramientas propias del agente (terminal, edición de archivos) siguen el modo del agente, que puedes elegir en la cabecera del panel de chat si dispone de modos.",
|
||||
"ui.ai_modes_scope": "Estos ajustes se aplican a las herramientas de OneCAD. Las herramientas propias del agente (terminal, edición de archivos) siguen el modo del agente, que puedes elegir en la cabecera del panel de chat si dispone de modos.",
|
||||
"ui.ai_name": "Nombre",
|
||||
"ui.ai_need_command": "Introduce el comando que inicia el agente",
|
||||
"ui.ai_need_model": "Introduce el nombre del modelo",
|
||||
@@ -1482,31 +1482,31 @@
|
||||
"ui.zoom": "Zoom",
|
||||
"ui.zoom_to_cursor_tip": "La rueda hace zoom hacia el punto bajo el cursor (desactivado: hacia el centro de la vista)",
|
||||
"ui.zoom_to_the_cursor": "Zoom hacia el cursor",
|
||||
"update.available": "SolidCraft {version} está disponible",
|
||||
"update.available": "OneCAD {version} está disponible",
|
||||
"update.button": "Actualizar",
|
||||
"update.button_tip": "SolidCraft {version} está disponible",
|
||||
"update.button_tip": "OneCAD {version} está disponible",
|
||||
"update.button_version": "Actualizar a {version}",
|
||||
"update.check_automatically": "Buscar actualizaciones automáticamente",
|
||||
"update.check_now": "Comprobar ahora",
|
||||
"update.checking": "Comprobando…",
|
||||
"update.copy": "Copiar",
|
||||
"update.dmg_opened": "La imagen de disco está abierta y verificada: arrastra SolidCraft a Aplicaciones, sustituye la copia anterior y reinicia SolidCraft.",
|
||||
"update.dmg_opened": "La imagen de disco está abierta y verificada: arrastra OneCAD a Aplicaciones, sustituye la copia anterior y reinicia OneCAD.",
|
||||
"update.download": "Descargar",
|
||||
"update.download_open": "Descargar y abrir",
|
||||
"update.downloading": "Descargando y verificando… {done} de {total} MB",
|
||||
"update.failed": "No se ha podido comprobar: {error}",
|
||||
"update.file": "La descarga obtiene {file}.",
|
||||
"update.install_failed": "No se ha podido instalar la actualización: {error}. No se ha modificado nada; la opción Descargar obtiene el paquete.",
|
||||
"update.install_msi": "Instalar (cierra SolidCraft)",
|
||||
"update.installed": "SolidCraft {version} está instalado. Reinicia para utilizarlo.",
|
||||
"update.installer_started": "El instalador se iniciará cuando se cierre SolidCraft.",
|
||||
"update.install_msi": "Instalar (cierra OneCAD)",
|
||||
"update.installed": "OneCAD {version} está instalado. Reinicia para utilizarlo.",
|
||||
"update.installer_started": "El instalador se iniciará cuando se cierre OneCAD.",
|
||||
"update.later": "Recordármelo más tarde",
|
||||
"update.no_notes": "Esta versión no tiene notas.",
|
||||
"update.package_hint": "Esta copia procede de {kind}. Descarga el paquete nuevo e instálalo con:",
|
||||
"update.prefs_dev": "Esta es una compilación de desarrollo ({version}): no busca actualizaciones.",
|
||||
"update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): ejecuta {command} o utiliza el centro de software. SolidCraft no lo comprueba.",
|
||||
"update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): ejecuta {command} o utiliza el centro de software. OneCAD no lo comprueba.",
|
||||
"update.prefs_label": "Actualizaciones",
|
||||
"update.prefs_note": "Esta es la versión {version} de SolidCraft ({kind}). La comprobación consulta en GitHub la última versión y no envía ningún otro dato.",
|
||||
"update.prefs_note": "Esta es la versión {version} de OneCAD ({kind}). La comprobación consulta en GitHub la última versión y no envía ningún otro dato.",
|
||||
"update.release_page": "Abrir página de la versión",
|
||||
"update.restart": "Reiniciar ahora",
|
||||
"update.restart_later": "Más tarde",
|
||||
@@ -1514,7 +1514,7 @@
|
||||
"update.skip": "Omitir esta versión",
|
||||
"update.system_hint": "Esta copia es de tipo {kind}. Actualízala con:",
|
||||
"update.title": "Actualización disponible",
|
||||
"update.up_to_date": "SolidCraft está actualizado.",
|
||||
"update.up_to_date": "OneCAD está actualizado.",
|
||||
"update.update_now": "Actualizar ahora",
|
||||
"update.you_have": "Tienes la versión {version}."
|
||||
}
|
||||
+25
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Cliquer sur Submit new issue sur GitHub pour envoyer votre rapport.",
|
||||
"A new configuration, a copy of the active one": "Nouvelle configuration, copie de la configuration active",
|
||||
"AI": "IA",
|
||||
"About SolidCraft": "À propos de SolidCraft",
|
||||
"About OneCAD": "À propos de OneCAD",
|
||||
"Acceleration": "Accélération",
|
||||
"Active": "Actif",
|
||||
"Active rule": "Règle active",
|
||||
@@ -252,7 +252,7 @@
|
||||
"Sketch plane has no body-face attachment.": "Le plan d'esquisse n'est rattaché à aucune face de corps.",
|
||||
"Sketch points": "Points d'esquisse",
|
||||
"Slot width": "Largeur de rainure",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft s'est fermé avant l'enregistrement de ces conceptions. Leur dernier enregistrement automatique peut être ouvert.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD s'est fermé avant l'enregistrement de ces conceptions. Leur dernier enregistrement automatique peut être ouvert.",
|
||||
"Speed": "Vitesse",
|
||||
"Stand it up": "Redresser",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Redresser : un quart de tour autour de X, sous forme d'une fonction Déplacer que vous pouvez annuler.",
|
||||
@@ -693,18 +693,18 @@
|
||||
"home.report_issue": "Signaler un problème",
|
||||
"home.samples": "Exemples",
|
||||
"home.whats_new": "Nouveautés",
|
||||
"install.button_tip": "Installer SolidCraft comme une app : il s'ouvre dans sa propre fenêtre et fonctionne hors ligne",
|
||||
"install.button_tip": "Installer OneCAD comme une app : il s'ouvre dans sa propre fenêtre et fonctionne hors ligne",
|
||||
"install.dock_1": "Choisissez Fichier › Ajouter au Dock.",
|
||||
"install.dock_2": "Cliquez sur Ajouter.",
|
||||
"install.dock_after": "SolidCraft s'ouvre ensuite dans sa propre fenêtre depuis le Dock, et fonctionne hors ligne.",
|
||||
"install.dock_after": "OneCAD s'ouvre ensuite dans sa propre fenêtre depuis le Dock, et fonctionne hors ligne.",
|
||||
"install.done": "Terminé",
|
||||
"install.home_screen_1": "Touchez Partager (dans le menu … s'il n'est pas dans la barre d'outils).",
|
||||
"install.home_screen_2": "Choisissez Sur l'écran d'accueil.",
|
||||
"install.home_screen_3": "Touchez Ajouter.",
|
||||
"install.home_screen_after": "SolidCraft s'ouvre ensuite depuis son icône sur l'écran d'accueil, sans les barres de Safari, et fonctionne hors ligne.",
|
||||
"install.home_screen_after": "OneCAD s'ouvre ensuite depuis son icône sur l'écran d'accueil, sans les barres de Safari, et fonctionne hors ligne.",
|
||||
"install.menu": "Installer l'app…",
|
||||
"install.steps_intro": "Safari installe les apps web depuis ses propres menus :",
|
||||
"install.title": "Installer SolidCraft",
|
||||
"install.title": "Installer OneCAD",
|
||||
"joint.from_bodies_hint": "Chaque corps sélectionné passe dans un nouveau composant à lui et reste où il est.",
|
||||
"joint.one_body": "Les deux sélections sont sur {body}. Choisissez la seconde sur une autre pièce.",
|
||||
"joint.one_component": "Ces pièces sont des corps d'un même composant : {bodies}. Une liaison déplace un composant par rapport à un autre, chaque pièce a donc besoin de son propre composant.",
|
||||
@@ -788,10 +788,10 @@
|
||||
"settings.frame_rate_note": "Affiche les images par seconde dans le coin inférieur droit de la vue. Tant qu'elle est activée, la vue est redessinée en continu.",
|
||||
"settings.measure_axis_distances": "Afficher les cotes X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Afficher les cotes suivant les axes du repère global en plus de la distance totale pendant la mesure.",
|
||||
"settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de SolidCraft",
|
||||
"settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de OneCAD",
|
||||
"settings.msaa_reset": "{setting} : {requested}× n'est pas pris en charge par ce périphérique graphique ; la valeur est désormais {reset}× (pris en charge : {supported}).",
|
||||
"settings.msaa_unsupported": "Non pris en charge par ce périphérique graphique",
|
||||
"settings.reset_intro": "Ces réglages ne fonctionnent pas sur cet ordinateur et empêcheraient SolidCraft de démarrer ; ils ont donc été modifiés :",
|
||||
"settings.reset_intro": "Ces réglages ne fonctionnent pas sur cet ordinateur et empêcheraient OneCAD de démarrer ; ils ont donc été modifiés :",
|
||||
"settings.reset_title": "Des réglages ont été modifiés",
|
||||
"settings.supersampling_note": "Dessine la vue à une résolution plus élevée puis la réduit : arêtes, lignes et grille plus lisses sur les écrans de définition inférieure à la 4K. Cela coûte du temps GPU : environ 2,25× plus de pixels à 1,5× et 4× plus à 2×.",
|
||||
"sketch.degrees_of_freedom": "{count} degrés de liberté",
|
||||
@@ -842,8 +842,8 @@
|
||||
"ui.agent_chat_attach_view": "Joindre la vue",
|
||||
"ui.agent_chat_attach_view_tip": "Envoyer une image de la vue actuelle avec le message",
|
||||
"ui.agent_chat_auth_agent": "S'authentifier avec {name}",
|
||||
"ui.agent_chat_auth_env": "Définir {vars} dans votre environnement, puis redémarrer SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "L'agent demande une authentification. Vous vous authentifiez auprès de l'agent lui-même ; SolidCraft ne voit jamais votre mot de passe ni vos clés.",
|
||||
"ui.agent_chat_auth_env": "Définir {vars} dans votre environnement, puis redémarrer OneCAD.",
|
||||
"ui.agent_chat_auth_needed": "L'agent demande une authentification. Vous vous authentifiez auprès de l'agent lui-même ; OneCAD ne voit jamais votre mot de passe ni vos clés.",
|
||||
"ui.agent_chat_auth_none": "L'agent ne propose ici aucun moyen d'authentification : s'authentifier comme le décrit sa propre documentation.",
|
||||
"ui.agent_chat_auth_retry": "Je suis authentifié : reconnecter",
|
||||
"ui.agent_chat_auth_terminal": "Exécuter ceci dans un terminal, puis appuyer de nouveau sur Connecter :",
|
||||
@@ -865,7 +865,7 @@
|
||||
"ui.agent_chat_edit": "Modifier",
|
||||
"ui.agent_chat_exited": "L'agent s'est arrêté.",
|
||||
"ui.agent_chat_failed_start": "L'agent n'a pas pu démarrer : {error}",
|
||||
"ui.agent_chat_intro": "Discuter avec un agent de programmation que vous utilisez déjà, ou avec un point d'accès de modèle de votre choix. Il travaille sur la conception ouverte avec les outils de SolidCraft : ses modifications apparaissent au fur et à mesure et s'annulent en une seule étape par message. Ce qu'il peut faire sans demander se règle dans Préférences › IA.",
|
||||
"ui.agent_chat_intro": "Discuter avec un agent de programmation que vous utilisez déjà, ou avec un point d'accès de modèle de votre choix. Il travaille sur la conception ouverte avec les outils de OneCAD : ses modifications apparaissent au fur et à mesure et s'annulent en une seule étape par message. Ce qu'il peut faire sans demander se règle dans Préférences › IA.",
|
||||
"ui.agent_chat_key_in_env": "clé définie",
|
||||
"ui.agent_chat_left": "Gauche",
|
||||
"ui.agent_chat_limit": "L'agent a atteint la limite de {limit} modifications pour un message. Envoyez un autre message pour qu'il poursuive.",
|
||||
@@ -886,7 +886,7 @@
|
||||
"ui.agent_chat_placeholder": "Demander à l'agent de modifier la conception…",
|
||||
"ui.agent_chat_placeholder_busy": "Mettre un message en file d'attente pour quand l'agent aura terminé (Ctrl+Enter : envoyer maintenant)",
|
||||
"ui.agent_chat_privacy": "Rien n'est envoyé ailleurs qu'à l'agent que vous lancez, sous votre propre compte.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft n'envoie rien nulle part : l'agent que vous lancez dialogue avec son propre service, sous votre propre compte. SolidCraft ne voit ni ne stocke jamais de clé ni de mot de passe.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD n'envoie rien nulle part : l'agent que vous lancez dialogue avec son propre service, sous votre propre compte. OneCAD ne voit ni ne stocke jamais de clé ni de mot de passe.",
|
||||
"ui.agent_chat_queue": "Mettre en file",
|
||||
"ui.agent_chat_queue_full": "Trop de messages sont en attente : retirez-en un ou attendez l'agent.",
|
||||
"ui.agent_chat_queued": "En file d'attente",
|
||||
@@ -925,7 +925,7 @@
|
||||
"ui.ai_add_installed": "Agent installé",
|
||||
"ui.ai_add_installed_note": "Une entrée à vous pour un agent installé, par exemple avec d'autres arguments. Les agents installés sont listés automatiquement.",
|
||||
"ui.ai_added": "ajouté",
|
||||
"ui.ai_agent_mode_tip": "Le mode propre à l'agent : ses propres outils (shell, modifications de fichiers) le suivent. Les outils de SolidCraft suivent les autorisations de Préférences › IA.",
|
||||
"ui.ai_agent_mode_tip": "Le mode propre à l'agent : ses propres outils (shell, modifications de fichiers) le suivent. Les outils de OneCAD suivent les autorisations de Préférences › IA.",
|
||||
"ui.ai_agents": "Agents",
|
||||
"ui.ai_always_allow": "Toujours autoriser",
|
||||
"ui.ai_always_allow_tip": "Autoriser {tool} pour cet agent dorénavant, sans demander",
|
||||
@@ -933,7 +933,7 @@
|
||||
"ui.ai_always_allowed_none": "Rien n'est encore toujours autorisé. Les réponses « Toujours autoriser » données dans le chat sont listées ici.",
|
||||
"ui.ai_auto_allowed": "Autorisé automatiquement : {title} ({why})",
|
||||
"ui.ai_auto_mode": "votre réglage des autorisations",
|
||||
"ui.ai_auto_ours": "SolidCraft vérifie lui-même ses propres outils",
|
||||
"ui.ai_auto_ours": "OneCAD vérifie lui-même ses propres outils",
|
||||
"ui.ai_auto_remembered": "vous avez choisi Toujours autoriser",
|
||||
"ui.ai_bad_url": "Saisir une URL commençant par http:// ou https://",
|
||||
"ui.ai_base_url": "URL de base",
|
||||
@@ -947,7 +947,7 @@
|
||||
"ui.ai_key": "Clé d'API",
|
||||
"ui.ai_key_hint": "facultative ; conservée dans le trousseau de clés du système",
|
||||
"ui.ai_key_needs_https": "Une clé n'est envoyée qu'en HTTPS, ou vers cet ordinateur.",
|
||||
"ui.ai_key_note": "Stockée dans le trousseau de clés du système, jamais dans les réglages de SolidCraft. Laisser vide pour un serveur local.",
|
||||
"ui.ai_key_note": "Stockée dans le trousseau de clés du système, jamais dans les réglages de OneCAD. Laisser vide pour un serveur local.",
|
||||
"ui.ai_keychain_error": "Le trousseau de clés du système a refusé : {error}",
|
||||
"ui.ai_mode_all": "Tout autoriser",
|
||||
"ui.ai_mode_all_note": "Rien ne demande d'abord. Les rapports de plantage restent inaccessibles et les limites de dossiers s'appliquent toujours.",
|
||||
@@ -958,7 +958,7 @@
|
||||
"ui.ai_mode_edits_note": "Les commandes de modélisation s'exécutent sans demander ; l'enregistrement, l'ouverture, l'exportation et les autres actions sur les fichiers ou destructrices demandent toujours.",
|
||||
"ui.ai_mode_global": "Comme défini ci-dessus",
|
||||
"ui.ai_model": "Modèle",
|
||||
"ui.ai_modes_scope": "Ces réglages concernent les outils de SolidCraft. Les outils propres à un agent (son shell, ses modifications de fichiers) suivent le mode de l'agent, que vous choisissez dans l'en-tête du panneau de chat lorsque l'agent propose des modes.",
|
||||
"ui.ai_modes_scope": "Ces réglages concernent les outils de OneCAD. Les outils propres à un agent (son shell, ses modifications de fichiers) suivent le mode de l'agent, que vous choisissez dans l'en-tête du panneau de chat lorsque l'agent propose des modes.",
|
||||
"ui.ai_name": "Nom",
|
||||
"ui.ai_need_command": "Saisir la commande qui lance l'agent",
|
||||
"ui.ai_need_model": "Saisir le nom du modèle",
|
||||
@@ -1482,31 +1482,31 @@
|
||||
"ui.zoom": "Zoom",
|
||||
"ui.zoom_to_cursor_tip": "La molette zoome vers le point sous le curseur (désactivé : vers le centre de la vue)",
|
||||
"ui.zoom_to_the_cursor": "Zoom vers le curseur",
|
||||
"update.available": "SolidCraft {version} est disponible",
|
||||
"update.available": "OneCAD {version} est disponible",
|
||||
"update.button": "Mettre à jour",
|
||||
"update.button_tip": "SolidCraft {version} est disponible",
|
||||
"update.button_tip": "OneCAD {version} est disponible",
|
||||
"update.button_version": "Mettre à jour vers {version}",
|
||||
"update.check_automatically": "Rechercher automatiquement les mises à jour",
|
||||
"update.check_now": "Vérifier maintenant",
|
||||
"update.checking": "Vérification…",
|
||||
"update.copy": "Copier",
|
||||
"update.dmg_opened": "L'image disque est ouverte et vérifiée : faites glisser SolidCraft dans Applications en remplaçant l'ancienne copie, puis redémarrez SolidCraft.",
|
||||
"update.dmg_opened": "L'image disque est ouverte et vérifiée : faites glisser OneCAD dans Applications en remplaçant l'ancienne copie, puis redémarrez OneCAD.",
|
||||
"update.download": "Télécharger",
|
||||
"update.download_open": "Télécharger et ouvrir",
|
||||
"update.downloading": "Téléchargement et vérification… {done} sur {total} Mo",
|
||||
"update.failed": "Vérification impossible : {error}",
|
||||
"update.file": "Le téléchargement récupère {file}.",
|
||||
"update.install_failed": "La mise à jour ne s'est pas installée : {error}. Rien n'a été modifié ; Télécharger récupère plutôt le paquet.",
|
||||
"update.install_msi": "Installer (ferme SolidCraft)",
|
||||
"update.installed": "SolidCraft {version} est installé. Redémarrez pour l'utiliser.",
|
||||
"update.installer_started": "L'installateur démarre à la fermeture de SolidCraft.",
|
||||
"update.install_msi": "Installer (ferme OneCAD)",
|
||||
"update.installed": "OneCAD {version} est installé. Redémarrez pour l'utiliser.",
|
||||
"update.installer_started": "L'installateur démarre à la fermeture de OneCAD.",
|
||||
"update.later": "Me le rappeler plus tard",
|
||||
"update.no_notes": "Cette version n'a pas de notes.",
|
||||
"update.package_hint": "Cette copie provient d'un {kind}. Téléchargez le nouveau paquet, puis installez-le avec :",
|
||||
"update.prefs_dev": "Ceci est une version de développement ({version}) : elle ne vérifie pas les mises à jour.",
|
||||
"update.prefs_flatpak": "Flatpak met à jour cette copie ({version}) : exécutez {command} ou utilisez votre logithèque. SolidCraft ne vérifie pas.",
|
||||
"update.prefs_flatpak": "Flatpak met à jour cette copie ({version}) : exécutez {command} ou utilisez votre logithèque. OneCAD ne vérifie pas.",
|
||||
"update.prefs_label": "Mises à jour",
|
||||
"update.prefs_note": "Ceci est SolidCraft {version} ({kind}). Une vérification interroge GitHub sur la dernière version et n'envoie rien d'autre.",
|
||||
"update.prefs_note": "Ceci est OneCAD {version} ({kind}). Une vérification interroge GitHub sur la dernière version et n'envoie rien d'autre.",
|
||||
"update.release_page": "Ouvrir la page de la version",
|
||||
"update.restart": "Redémarrer maintenant",
|
||||
"update.restart_later": "Plus tard",
|
||||
@@ -1514,7 +1514,7 @@
|
||||
"update.skip": "Ignorer cette version",
|
||||
"update.system_hint": "Cette copie est un {kind}. Mettez-la à jour avec :",
|
||||
"update.title": "Mise à jour disponible",
|
||||
"update.up_to_date": "SolidCraft est à jour.",
|
||||
"update.up_to_date": "OneCAD est à jour.",
|
||||
"update.update_now": "Mettre à jour maintenant",
|
||||
"update.you_have": "Vous avez la version {version}."
|
||||
}
|
||||
+25
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Preme Submit new issue en GitHub para enviar o informe.",
|
||||
"A new configuration, a copy of the active one": "Unha configuración nova, copia da activa",
|
||||
"AI": "IA",
|
||||
"About SolidCraft": "Acerca de SolidCraft",
|
||||
"About OneCAD": "Acerca de OneCAD",
|
||||
"Active": "Activa",
|
||||
"Active rule": "Regra activa",
|
||||
"Add": "Engadir",
|
||||
@@ -239,7 +239,7 @@
|
||||
"Sketch plane has no body-face attachment.": "O plano do bosquexo non está ancorado a ningunha cara dun corpo.",
|
||||
"Sketch points": "Puntos do bosquexo",
|
||||
"Slot width": "Largura da ranura",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft pechouse antes de gardar estes deseños. Pódese abrir a súa última gravación automática.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD pechouse antes de gardar estes deseños. Pódese abrir a súa última gravación automática.",
|
||||
"Speed": "Velocidade",
|
||||
"Stand it up": "Poñela de pé",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Poñela de pé: un cuarto de volta arredor de X, como unha operación Mover que podes desfacer.",
|
||||
@@ -673,18 +673,18 @@
|
||||
"home.report_issue": "Informar dunha incidencia",
|
||||
"home.samples": "Exemplos",
|
||||
"home.whats_new": "Novidades",
|
||||
"install.button_tip": "Instala SolidCraft como aplicación: ábrese na súa propia xanela e funciona sen conexión",
|
||||
"install.button_tip": "Instala OneCAD como aplicación: ábrese na súa propia xanela e funciona sen conexión",
|
||||
"install.dock_1": "Escolle Ficheiro › Engadir ao Dock.",
|
||||
"install.dock_2": "Fai clic en Engadir.",
|
||||
"install.dock_after": "Despois, SolidCraft ábrese na súa propia xanela desde o Dock e funciona sen conexión.",
|
||||
"install.dock_after": "Despois, OneCAD ábrese na súa propia xanela desde o Dock e funciona sen conexión.",
|
||||
"install.done": "Feito",
|
||||
"install.home_screen_1": "Toca Compartir (no menú … se non está na barra de ferramentas).",
|
||||
"install.home_screen_2": "Escolle Engadir á pantalla de inicio.",
|
||||
"install.home_screen_3": "Toca Engadir.",
|
||||
"install.home_screen_after": "Despois, SolidCraft ábrese desde a súa icona na pantalla de inicio, sen as barras de Safari, e funciona sen conexión.",
|
||||
"install.home_screen_after": "Despois, OneCAD ábrese desde a súa icona na pantalla de inicio, sen as barras de Safari, e funciona sen conexión.",
|
||||
"install.menu": "Instalar a aplicación…",
|
||||
"install.steps_intro": "Safari instala as aplicacións web desde os seus propios menús:",
|
||||
"install.title": "Instalar SolidCraft",
|
||||
"install.title": "Instalar OneCAD",
|
||||
"joint.from_bodies_hint": "Cada corpo escollido pasa a un compoñente novo propio e queda onde está.",
|
||||
"joint.one_body": "As dúas seleccións están en {body}. Escolle a segunda noutra peza.",
|
||||
"joint.one_component": "Estas pezas son corpos dun mesmo compoñente: {bodies}. Unha unión move un compoñente respecto doutro, así que cada peza precisa o seu propio compoñente.",
|
||||
@@ -764,10 +764,10 @@
|
||||
"settings.frame_rate_note": "Mostra as imaxes por segundo na esquina inferior dereita da vista. Mentres está activado, a vista redebúxase continuamente.",
|
||||
"settings.measure_axis_distances": "Mostrar as distancias en X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostra as compoñentes da distancia segundo os eixes do mundo xunto á distancia total mentres Medir está aberto.",
|
||||
"settings.msaa_applies_on_restart": "O antialiasing aplicarase a próxima vez que se inicie SolidCraft",
|
||||
"settings.msaa_applies_on_restart": "O antialiasing aplicarase a próxima vez que se inicie OneCAD",
|
||||
"settings.msaa_reset": "{setting}: este dispositivo gráfico non admite {requested}×; agora é {reset}× (admitidos: {supported}).",
|
||||
"settings.msaa_unsupported": "Non admitido por este dispositivo gráfico",
|
||||
"settings.reset_intro": "Estes axustes non funcionan neste ordenador e impedirían que SolidCraft se iniciase, así que se cambiaron:",
|
||||
"settings.reset_intro": "Estes axustes non funcionan neste ordenador e impedirían que OneCAD se iniciase, así que se cambiaron:",
|
||||
"settings.reset_title": "Cambiáronse os axustes",
|
||||
"settings.supersampling_note": "Debuxa a vista a unha resolución maior e fíltraa cara abaixo: arestas, liñas e grella máis suaves en pantallas por debaixo de 4K. Consome tempo de GPU: uns 2,25× píxeles a 1,5× e 4× a 2×.",
|
||||
"sketch.degrees_of_freedom": "{count} graos de liberdade",
|
||||
@@ -814,8 +814,8 @@
|
||||
"ui.agent_chat_attach_view": "Achegar a vista",
|
||||
"ui.agent_chat_attach_view_tip": "Envía unha imaxe da vista actual coa mensaxe",
|
||||
"ui.agent_chat_auth_agent": "Iniciar sesión con {name}",
|
||||
"ui.agent_chat_auth_env": "Define {vars} no teu contorno e reinicia SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "O axente pídeche que inicies sesión. Inicias sesión co propio axente; SolidCraft nunca ve o teu contrasinal nin as túas claves.",
|
||||
"ui.agent_chat_auth_env": "Define {vars} no teu contorno e reinicia OneCAD.",
|
||||
"ui.agent_chat_auth_needed": "O axente pídeche que inicies sesión. Inicias sesión co propio axente; OneCAD nunca ve o teu contrasinal nin as túas claves.",
|
||||
"ui.agent_chat_auth_none": "O axente non ofrece aquí ningún método para iniciar sesión: inicia sesión como indica a súa propia documentación.",
|
||||
"ui.agent_chat_auth_retry": "Xa iniciei sesión: conectar de novo",
|
||||
"ui.agent_chat_auth_terminal": "Executa isto nun terminal e despois preme Conectar de novo:",
|
||||
@@ -837,7 +837,7 @@
|
||||
"ui.agent_chat_edit": "Editar",
|
||||
"ui.agent_chat_exited": "O axente detívose.",
|
||||
"ui.agent_chat_failed_start": "Non se puido iniciar o axente: {error}",
|
||||
"ui.agent_chat_intro": "Conversa cun axente de programación que xa uses, ou cun punto de acceso de modelo que escollas. Traballa no deseño aberto a través das ferramentas de SolidCraft: os seus cambios vense mentres os fai e desfanse nun paso por mensaxe. O que pode facer sen preguntar defínese en Preferencias > IA.",
|
||||
"ui.agent_chat_intro": "Conversa cun axente de programación que xa uses, ou cun punto de acceso de modelo que escollas. Traballa no deseño aberto a través das ferramentas de OneCAD: os seus cambios vense mentres os fai e desfanse nun paso por mensaxe. O que pode facer sen preguntar defínese en Preferencias > IA.",
|
||||
"ui.agent_chat_key_in_env": "clave configurada",
|
||||
"ui.agent_chat_left": "Esquerda",
|
||||
"ui.agent_chat_limit": "O axente chegou ao límite de {limit} cambios por mensaxe. Envía outra mensaxe para que continúe.",
|
||||
@@ -858,7 +858,7 @@
|
||||
"ui.agent_chat_placeholder": "Pídelle ao axente que cambie o deseño…",
|
||||
"ui.agent_chat_placeholder_busy": "Pon unha mensaxe na cola para cando remate o axente (Ctrl+Intro: enviar agora)",
|
||||
"ui.agent_chat_privacy": "Nada vai a ningún sitio agás ao axente que executas, coa túa propia conta.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft non envía nada a ningún sitio: o axente que executas fala co seu propio servizo coa túa propia conta. SolidCraft nunca ve nin garda ningunha clave nin contrasinal.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD non envía nada a ningún sitio: o axente que executas fala co seu propio servizo coa túa propia conta. OneCAD nunca ve nin garda ningunha clave nin contrasinal.",
|
||||
"ui.agent_chat_queue": "Pór na cola",
|
||||
"ui.agent_chat_queue_full": "Hai demasiadas mensaxes agardando: quita unha ou agarda polo axente.",
|
||||
"ui.agent_chat_queued": "Na cola",
|
||||
@@ -897,7 +897,7 @@
|
||||
"ui.ai_add_installed": "Axente instalado",
|
||||
"ui.ai_add_installed_note": "Unha entrada propia para un axente instalado, por exemplo con outros argumentos. Os axentes instalados lístanse por si sós.",
|
||||
"ui.ai_added": "engadido",
|
||||
"ui.ai_agent_mode_tip": "O modo propio do axente: as súas propias ferramentas (shell, edición de ficheiros) seguen ese modo. As ferramentas de SolidCraft seguen os permisos de Preferencias > IA.",
|
||||
"ui.ai_agent_mode_tip": "O modo propio do axente: as súas propias ferramentas (shell, edición de ficheiros) seguen ese modo. As ferramentas de OneCAD seguen os permisos de Preferencias > IA.",
|
||||
"ui.ai_agents": "Axentes",
|
||||
"ui.ai_always_allow": "Permitir sempre",
|
||||
"ui.ai_always_allow_tip": "Permitir {tool} a este axente de agora en diante, sen preguntar",
|
||||
@@ -905,7 +905,7 @@
|
||||
"ui.ai_always_allowed_none": "Aínda non hai nada permitido sempre. As respostas «Permitir sempre» que deas no chat lístanse aquí.",
|
||||
"ui.ai_auto_allowed": "Permitido automaticamente: {title} ({why})",
|
||||
"ui.ai_auto_mode": "a túa configuración de permisos",
|
||||
"ui.ai_auto_ours": "SolidCraft comproba as súas propias ferramentas",
|
||||
"ui.ai_auto_ours": "OneCAD comproba as súas propias ferramentas",
|
||||
"ui.ai_auto_remembered": "escolliches Permitir sempre",
|
||||
"ui.ai_bad_url": "Introduce un URL que comece por http:// ou https://",
|
||||
"ui.ai_base_url": "URL base",
|
||||
@@ -919,7 +919,7 @@
|
||||
"ui.ai_key": "Clave da API",
|
||||
"ui.ai_key_hint": "opcional; gárdase no chaveiro do sistema",
|
||||
"ui.ai_key_needs_https": "A clave só se envía por HTTPS ou a este ordenador.",
|
||||
"ui.ai_key_note": "Gárdase no chaveiro do sistema, nunca nos axustes de SolidCraft. Déixaa baleira para un servidor local.",
|
||||
"ui.ai_key_note": "Gárdase no chaveiro do sistema, nunca nos axustes de OneCAD. Déixaa baleira para un servidor local.",
|
||||
"ui.ai_keychain_error": "O chaveiro do sistema rexeitouno: {error}",
|
||||
"ui.ai_mode_all": "Permitir todo",
|
||||
"ui.ai_mode_all_note": "Nada pregunta antes. Os informes de fallos seguen fóra do seu alcance e os límites de cartafoles seguen aplicándose.",
|
||||
@@ -930,7 +930,7 @@
|
||||
"ui.ai_mode_edits_note": "As ordes de modelado execútanse sen preguntar; gardar, abrir, exportar e outras accións con ficheiros ou destrutivas seguen preguntando.",
|
||||
"ui.ai_mode_global": "Como se definiu arriba",
|
||||
"ui.ai_model": "Modelo",
|
||||
"ui.ai_modes_scope": "Estes axustes cobren as ferramentas de SolidCraft. As ferramentas propias dun axente (o seu shell, as súas edicións de ficheiros) seguen o modo propio do axente, que escolles na cabeceira do panel de chat cando o axente ten modos.",
|
||||
"ui.ai_modes_scope": "Estes axustes cobren as ferramentas de OneCAD. As ferramentas propias dun axente (o seu shell, as súas edicións de ficheiros) seguen o modo propio do axente, que escolles na cabeceira do panel de chat cando o axente ten modos.",
|
||||
"ui.ai_name": "Nome",
|
||||
"ui.ai_need_command": "Introduce a orde que inicia o axente",
|
||||
"ui.ai_need_model": "Introduce o nome do modelo",
|
||||
@@ -1445,31 +1445,31 @@
|
||||
"ui.zoom": "Zoom",
|
||||
"ui.zoom_to_cursor_tip": "A roda fai zoom cara ao punto baixo o cursor (desactivado: cara ao centro da vista)",
|
||||
"ui.zoom_to_the_cursor": "Zoom cara ao cursor",
|
||||
"update.available": "Está dispoñible SolidCraft {version}",
|
||||
"update.available": "Está dispoñible OneCAD {version}",
|
||||
"update.button": "Actualizar",
|
||||
"update.button_tip": "Está dispoñible SolidCraft {version}",
|
||||
"update.button_tip": "Está dispoñible OneCAD {version}",
|
||||
"update.button_version": "Actualizar a {version}",
|
||||
"update.check_automatically": "Buscar actualizacións automaticamente",
|
||||
"update.check_now": "Comprobar agora",
|
||||
"update.checking": "Comprobando…",
|
||||
"update.copy": "Copiar",
|
||||
"update.dmg_opened": "A imaxe de disco está aberta e verificada: arrastra SolidCraft a Aplicacións, substituíndo a copia antiga, e despois reinicia SolidCraft.",
|
||||
"update.dmg_opened": "A imaxe de disco está aberta e verificada: arrastra OneCAD a Aplicacións, substituíndo a copia antiga, e despois reinicia OneCAD.",
|
||||
"update.download": "Descargar",
|
||||
"update.download_open": "Descargar e abrir",
|
||||
"update.downloading": "Descargando e verificando… {done} de {total} MB",
|
||||
"update.failed": "Non se puido comprobar: {error}",
|
||||
"update.file": "Descargar obtén {file}.",
|
||||
"update.install_failed": "A actualización non se instalou: {error}. Non se cambiou nada; Descargar obtén o paquete no seu lugar.",
|
||||
"update.install_msi": "Instalar (pecha SolidCraft)",
|
||||
"update.installed": "SolidCraft {version} está instalado. Reinicia para usalo.",
|
||||
"update.installer_started": "O instalador iníciase cando se pecha SolidCraft.",
|
||||
"update.install_msi": "Instalar (pecha OneCAD)",
|
||||
"update.installed": "OneCAD {version} está instalado. Reinicia para usalo.",
|
||||
"update.installer_started": "O instalador iníciase cando se pecha OneCAD.",
|
||||
"update.later": "Lembrarmo máis tarde",
|
||||
"update.no_notes": "Esta versión non ten notas.",
|
||||
"update.package_hint": "Esta copia veu dun {kind}. Descarga o paquete novo e despois instálao con:",
|
||||
"update.prefs_dev": "Esta é unha compilación de desenvolvemento ({version}): non busca actualizacións.",
|
||||
"update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): executa {command} ou usa o teu centro de software. SolidCraft non comproba.",
|
||||
"update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): executa {command} ou usa o teu centro de software. OneCAD non comproba.",
|
||||
"update.prefs_label": "Actualizacións",
|
||||
"update.prefs_note": "Esta é a versión {version} de SolidCraft ({kind}). Unha comprobación pídelle a GitHub a última versión e non envía nada máis.",
|
||||
"update.prefs_note": "Esta é a versión {version} de OneCAD ({kind}). Unha comprobación pídelle a GitHub a última versión e non envía nada máis.",
|
||||
"update.release_page": "Abrir a páxina da versión",
|
||||
"update.restart": "Reiniciar agora",
|
||||
"update.restart_later": "Máis tarde",
|
||||
@@ -1477,7 +1477,7 @@
|
||||
"update.skip": "Omitir esta versión",
|
||||
"update.system_hint": "Esta copia é un {kind}. Actualízaa con:",
|
||||
"update.title": "Actualización dispoñible",
|
||||
"update.up_to_date": "SolidCraft está actualizado.",
|
||||
"update.up_to_date": "OneCAD está actualizado.",
|
||||
"update.update_now": "Actualizar agora",
|
||||
"update.you_have": "Tes a versión {version}."
|
||||
}
|
||||
+25
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Fai clic su Submit new issue su GitHub per inviare la segnalazione.",
|
||||
"A new configuration, a copy of the active one": "Una nuova configurazione, copia di quella attiva",
|
||||
"AI": "IA",
|
||||
"About SolidCraft": "Informazioni su SolidCraft",
|
||||
"About OneCAD": "Informazioni su OneCAD",
|
||||
"Acceleration": "Accelerazione",
|
||||
"Active": "Attivo",
|
||||
"Active rule": "Regola attiva",
|
||||
@@ -249,7 +249,7 @@
|
||||
"Sketch plane has no body-face attachment.": "Il piano dello schizzo non è collegato a una faccia di un corpo.",
|
||||
"Sketch points": "Punti dello schizzo",
|
||||
"Slot width": "Larghezza dell'asola",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft si è chiuso prima del salvataggio di questi progetti. È possibile aprire il loro ultimo salvataggio automatico.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD si è chiuso prima del salvataggio di questi progetti. È possibile aprire il loro ultimo salvataggio automatico.",
|
||||
"Speed": "Velocità",
|
||||
"Stand it up": "Raddrizza",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Raddrizza: un quarto di giro attorno a X, come singola lavorazione Sposta annullabile.",
|
||||
@@ -689,18 +689,18 @@
|
||||
"home.report_issue": "Segnala un problema",
|
||||
"home.samples": "Esempi",
|
||||
"home.whats_new": "Novità",
|
||||
"install.button_tip": "Installa SolidCraft come applicazione: si apre in una finestra dedicata e funziona offline",
|
||||
"install.button_tip": "Installa OneCAD come applicazione: si apre in una finestra dedicata e funziona offline",
|
||||
"install.dock_1": "Scegli File › Aggiungi al Dock.",
|
||||
"install.dock_2": "Fai clic su Aggiungi.",
|
||||
"install.dock_after": "SolidCraft si aprirà dal Dock in una finestra dedicata e funzionerà offline.",
|
||||
"install.dock_after": "OneCAD si aprirà dal Dock in una finestra dedicata e funzionerà offline.",
|
||||
"install.done": "Fine",
|
||||
"install.home_screen_1": "Tocca Condividi (nel menu … se non è nella barra degli strumenti).",
|
||||
"install.home_screen_2": "Scegli Aggiungi alla schermata Home.",
|
||||
"install.home_screen_3": "Tocca Aggiungi.",
|
||||
"install.home_screen_after": "SolidCraft si aprirà dalla sua icona nella schermata Home, senza le barre degli strumenti di Safari, e funzionerà offline.",
|
||||
"install.home_screen_after": "OneCAD si aprirà dalla sua icona nella schermata Home, senza le barre degli strumenti di Safari, e funzionerà offline.",
|
||||
"install.menu": "Installa applicazione…",
|
||||
"install.steps_intro": "Safari installa le applicazioni web dai propri menu:",
|
||||
"install.title": "Installa SolidCraft",
|
||||
"install.title": "Installa OneCAD",
|
||||
"joint.from_bodies_hint": "Ogni corpo selezionato viene spostato in un nuovo componente distinto, mantenendo la sua posizione.",
|
||||
"joint.one_body": "Entrambe le selezioni sono su {body}. Seleziona il secondo elemento su un'altra parte.",
|
||||
"joint.one_component": "Queste parti sono corpi di un unico componente: {bodies}. Un giunto muove un componente rispetto a un altro, quindi ogni parte richiede un componente distinto.",
|
||||
@@ -784,10 +784,10 @@
|
||||
"settings.frame_rate_note": "Mostra i fotogrammi al secondo nell'angolo inferiore destro della vista. Quando è attivo, la vista viene ridisegnata continuamente.",
|
||||
"settings.measure_axis_distances": "Mostra distanze X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostra le componenti lungo gli assi globali accanto alla distanza totale quando Misura è aperto.",
|
||||
"settings.msaa_applies_on_restart": "L'antialiasing verrà applicato al prossimo avvio di SolidCraft",
|
||||
"settings.msaa_applies_on_restart": "L'antialiasing verrà applicato al prossimo avvio di OneCAD",
|
||||
"settings.msaa_reset": "{setting}: {requested}× non è supportato da questo dispositivo grafico; ora è {reset}× (supportati: {supported}).",
|
||||
"settings.msaa_unsupported": "Non supportato da questo dispositivo grafico",
|
||||
"settings.reset_intro": "Queste impostazioni non funzionano su questo computer e impedirebbero l'avvio di SolidCraft, quindi sono state modificate:",
|
||||
"settings.reset_intro": "Queste impostazioni non funzionano su questo computer e impedirebbero l'avvio di OneCAD, quindi sono state modificate:",
|
||||
"settings.reset_title": "Le impostazioni sono state modificate",
|
||||
"settings.supersampling_note": "Disegna la vista a una risoluzione maggiore e la riduce tramite filtro: spigoli, linee e griglia più uniformi sugli schermi sotto il 4K. Richiede tempo GPU: circa 2.25× i pixel a 1.5× e 4× a 2×.",
|
||||
"sketch.degrees_of_freedom": "{count} gradi di libertà",
|
||||
@@ -838,8 +838,8 @@
|
||||
"ui.agent_chat_attach_view": "Allega vista",
|
||||
"ui.agent_chat_attach_view_tip": "Invia un'immagine della vista corrente insieme al messaggio",
|
||||
"ui.agent_chat_auth_agent": "Accedi con {name}",
|
||||
"ui.agent_chat_auth_env": "Imposta {vars} nell'ambiente e riavvia SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "L'agente richiede l'accesso. L'accesso avviene tramite l'agente stesso; SolidCraft non vede mai password o chiavi.",
|
||||
"ui.agent_chat_auth_env": "Imposta {vars} nell'ambiente e riavvia OneCAD.",
|
||||
"ui.agent_chat_auth_needed": "L'agente richiede l'accesso. L'accesso avviene tramite l'agente stesso; OneCAD non vede mai password o chiavi.",
|
||||
"ui.agent_chat_auth_none": "L'agente non offre un metodo di accesso qui: accedi seguendo la sua documentazione.",
|
||||
"ui.agent_chat_auth_retry": "Ho effettuato l'accesso: riconnetti",
|
||||
"ui.agent_chat_auth_terminal": "Esegui questo comando in un terminale, poi premi di nuovo Connetti:",
|
||||
@@ -861,7 +861,7 @@
|
||||
"ui.agent_chat_edit": "Modifica",
|
||||
"ui.agent_chat_exited": "L'agente si è arrestato.",
|
||||
"ui.agent_chat_failed_start": "Impossibile avviare l'agente: {error}",
|
||||
"ui.agent_chat_intro": "Conversa con un agente di programmazione che già usi o con un endpoint di modello a tua scelta. Opera sul progetto aperto tramite gli strumenti di SolidCraft: le modifiche sono visibili mentre vengono eseguite e si annullano in un solo passo per messaggio. Le azioni consentite senza conferma si impostano in Preferenze > IA.",
|
||||
"ui.agent_chat_intro": "Conversa con un agente di programmazione che già usi o con un endpoint di modello a tua scelta. Opera sul progetto aperto tramite gli strumenti di OneCAD: le modifiche sono visibili mentre vengono eseguite e si annullano in un solo passo per messaggio. Le azioni consentite senza conferma si impostano in Preferenze > IA.",
|
||||
"ui.agent_chat_key_in_env": "chiave impostata",
|
||||
"ui.agent_chat_left": "Sinistra",
|
||||
"ui.agent_chat_limit": "L'agente ha raggiunto il limite di {limit} modifiche per messaggio. Invia un altro messaggio per farlo proseguire.",
|
||||
@@ -882,7 +882,7 @@
|
||||
"ui.agent_chat_placeholder": "Chiedi all'agente di modificare il progetto…",
|
||||
"ui.agent_chat_placeholder_busy": "Metti in coda un messaggio per quando l'agente avrà terminato (Ctrl+Enter: invia subito)",
|
||||
"ui.agent_chat_privacy": "I dati vengono inviati solo all'agente che esegui, con il tuo account.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft non invia dati ad alcun servizio: l'agente che esegui comunica con il proprio servizio usando il tuo account. SolidCraft non vede né memorizza mai chiavi o password.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD non invia dati ad alcun servizio: l'agente che esegui comunica con il proprio servizio usando il tuo account. OneCAD non vede né memorizza mai chiavi o password.",
|
||||
"ui.agent_chat_queue": "Metti in coda",
|
||||
"ui.agent_chat_queue_full": "Troppi messaggi in attesa: rimuovine uno o attendi l'agente.",
|
||||
"ui.agent_chat_queued": "In coda",
|
||||
@@ -921,7 +921,7 @@
|
||||
"ui.ai_add_installed": "Agente installato",
|
||||
"ui.ai_add_installed_note": "Una voce personalizzata per un agente installato, ad esempio con argomenti diversi. Gli agenti installati vengono elencati automaticamente.",
|
||||
"ui.ai_added": "aggiunto",
|
||||
"ui.ai_agent_mode_tip": "La modalità dell'agente: i suoi strumenti (shell, modifiche ai file) la rispettano. Gli strumenti di SolidCraft rispettano le autorizzazioni in Preferenze > IA.",
|
||||
"ui.ai_agent_mode_tip": "La modalità dell'agente: i suoi strumenti (shell, modifiche ai file) la rispettano. Gli strumenti di OneCAD rispettano le autorizzazioni in Preferenze > IA.",
|
||||
"ui.ai_agents": "Agenti",
|
||||
"ui.ai_always_allow": "Consenti sempre",
|
||||
"ui.ai_always_allow_tip": "Consenti {tool} a questo agente d'ora in poi, senza chiedere",
|
||||
@@ -929,7 +929,7 @@
|
||||
"ui.ai_always_allowed_none": "Nessuna azione è ancora sempre consentita. Le risposte «Consenti sempre» date nella chat sono elencate qui.",
|
||||
"ui.ai_auto_allowed": "Consentito automaticamente: {title} ({why})",
|
||||
"ui.ai_auto_mode": "la tua impostazione delle autorizzazioni",
|
||||
"ui.ai_auto_ours": "SolidCraft controlla direttamente i propri strumenti",
|
||||
"ui.ai_auto_ours": "OneCAD controlla direttamente i propri strumenti",
|
||||
"ui.ai_auto_remembered": "hai scelto Consenti sempre",
|
||||
"ui.ai_bad_url": "Inserisci un URL che inizi con http:// o https://",
|
||||
"ui.ai_base_url": "URL di base",
|
||||
@@ -943,7 +943,7 @@
|
||||
"ui.ai_key": "Chiave API",
|
||||
"ui.ai_key_hint": "facoltativa; conservata nel portachiavi di sistema",
|
||||
"ui.ai_key_needs_https": "La chiave viene inviata solo tramite HTTPS o a questo computer.",
|
||||
"ui.ai_key_note": "Memorizzata nel portachiavi di sistema, mai nelle impostazioni di SolidCraft. Lascia vuoto per un server locale.",
|
||||
"ui.ai_key_note": "Memorizzata nel portachiavi di sistema, mai nelle impostazioni di OneCAD. Lascia vuoto per un server locale.",
|
||||
"ui.ai_keychain_error": "Il portachiavi di sistema ha rifiutato l'operazione: {error}",
|
||||
"ui.ai_mode_all": "Consenti tutto",
|
||||
"ui.ai_mode_all_note": "Nessuna richiesta di conferma. I rapporti di arresto anomalo restano inaccessibili e i limiti delle cartelle restano validi.",
|
||||
@@ -954,7 +954,7 @@
|
||||
"ui.ai_mode_edits_note": "I comandi di modellazione vengono eseguiti senza chiedere; salvataggio, apertura, esportazione e altre azioni sui file o distruttive richiedono comunque conferma.",
|
||||
"ui.ai_mode_global": "Come impostato sopra",
|
||||
"ui.ai_model": "Modello",
|
||||
"ui.ai_modes_scope": "Queste impostazioni riguardano gli strumenti di SolidCraft. Gli strumenti dell'agente (shell, modifiche ai file) seguono la modalità dell'agente, selezionabile nell'intestazione del pannello chat quando l'agente supporta le modalità.",
|
||||
"ui.ai_modes_scope": "Queste impostazioni riguardano gli strumenti di OneCAD. Gli strumenti dell'agente (shell, modifiche ai file) seguono la modalità dell'agente, selezionabile nell'intestazione del pannello chat quando l'agente supporta le modalità.",
|
||||
"ui.ai_name": "Nome",
|
||||
"ui.ai_need_command": "Inserisci il comando che avvia l'agente",
|
||||
"ui.ai_need_model": "Inserisci il nome del modello",
|
||||
@@ -1473,31 +1473,31 @@
|
||||
"ui.zoom": "Zoom",
|
||||
"ui.zoom_to_cursor_tip": "La rotellina esegue lo zoom verso il punto sotto il cursore (se disattivato: verso il centro della vista)",
|
||||
"ui.zoom_to_the_cursor": "Zoom verso il cursore",
|
||||
"update.available": "È disponibile SolidCraft {version}",
|
||||
"update.available": "È disponibile OneCAD {version}",
|
||||
"update.button": "Aggiorna",
|
||||
"update.button_tip": "È disponibile SolidCraft {version}",
|
||||
"update.button_tip": "È disponibile OneCAD {version}",
|
||||
"update.button_version": "Aggiorna a {version}",
|
||||
"update.check_automatically": "Controlla automaticamente gli aggiornamenti",
|
||||
"update.check_now": "Controlla ora",
|
||||
"update.checking": "Controllo…",
|
||||
"update.copy": "Copia",
|
||||
"update.dmg_opened": "L'immagine disco è aperta e verificata: trascina SolidCraft in Applicazioni sostituendo la copia precedente, poi riavvia SolidCraft.",
|
||||
"update.dmg_opened": "L'immagine disco è aperta e verificata: trascina OneCAD in Applicazioni sostituendo la copia precedente, poi riavvia OneCAD.",
|
||||
"update.download": "Scarica",
|
||||
"update.download_open": "Scarica e apri",
|
||||
"update.downloading": "Download e verifica… {done} di {total} MB",
|
||||
"update.failed": "Impossibile controllare: {error}",
|
||||
"update.file": "Scarica permette di ottenere {file}.",
|
||||
"update.install_failed": "L'aggiornamento non è stato installato: {error}. Nessuna modifica effettuata; Scarica permette invece di ottenere il pacchetto.",
|
||||
"update.install_msi": "Installa (chiude SolidCraft)",
|
||||
"update.installed": "SolidCraft {version} è installato. Riavvia per usarlo.",
|
||||
"update.installer_started": "Il programma di installazione si avvia alla chiusura di SolidCraft.",
|
||||
"update.install_msi": "Installa (chiude OneCAD)",
|
||||
"update.installed": "OneCAD {version} è installato. Riavvia per usarlo.",
|
||||
"update.installer_started": "Il programma di installazione si avvia alla chiusura di OneCAD.",
|
||||
"update.later": "Ricordamelo più tardi",
|
||||
"update.no_notes": "Questa versione non contiene note.",
|
||||
"update.package_hint": "Questa copia proviene da un {kind}. Scarica il nuovo pacchetto, poi installalo con:",
|
||||
"update.prefs_dev": "Questa è una build di sviluppo ({version}): non controlla gli aggiornamenti.",
|
||||
"update.prefs_flatpak": "Questa copia ({version}) viene aggiornata da Flatpak: esegui {command} o usa il centro software. SolidCraft non effettua controlli.",
|
||||
"update.prefs_flatpak": "Questa copia ({version}) viene aggiornata da Flatpak: esegui {command} o usa il centro software. OneCAD non effettua controlli.",
|
||||
"update.prefs_label": "Aggiornamenti",
|
||||
"update.prefs_note": "Questa è la versione SolidCraft {version} ({kind}). Il controllo richiede a GitHub l'ultima versione e non invia altri dati.",
|
||||
"update.prefs_note": "Questa è la versione OneCAD {version} ({kind}). Il controllo richiede a GitHub l'ultima versione e non invia altri dati.",
|
||||
"update.release_page": "Apri pagina della versione",
|
||||
"update.restart": "Riavvia ora",
|
||||
"update.restart_later": "Più tardi",
|
||||
@@ -1505,7 +1505,7 @@
|
||||
"update.skip": "Ignora questa versione",
|
||||
"update.system_hint": "Questa copia è un {kind}. Aggiornala con:",
|
||||
"update.title": "Aggiornamento disponibile",
|
||||
"update.up_to_date": "SolidCraft è aggiornato.",
|
||||
"update.up_to_date": "OneCAD è aggiornato.",
|
||||
"update.update_now": "Aggiorna ora",
|
||||
"update.you_have": "La tua versione è {version}."
|
||||
}
|
||||
+30
-27
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. GitHubでSubmit new issueをクリックしてレポートを送信します。",
|
||||
"A new configuration, a copy of the active one": "アクティブな構成をコピーして新しい構成を作成",
|
||||
"AI": "AI",
|
||||
"About SolidCraft": "SolidCraft について",
|
||||
"About OneCAD": "OneCAD について",
|
||||
"Acceleration": "加速度",
|
||||
"Active": "アクティブ",
|
||||
"Active rule": "有効なルール",
|
||||
@@ -113,7 +113,7 @@
|
||||
"Faces": "面",
|
||||
"Faces / edges / vertices": "面 / エッジ / 頂点",
|
||||
"Favourite": "お気に入り",
|
||||
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "多くのCADではY軸を上向きとしているため、Z軸を上向きとするSolidCraftでは、読み込んだモデルが横倒しの向きで表示されることがあります。",
|
||||
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "多くのCADではY軸を上向きとしているため、Z軸を上向きとするOneCADでは、読み込んだモデルが横倒しの向きで表示されることがあります。",
|
||||
"Filter": "フィルター",
|
||||
"Find in Browser": "ブラウザで検索",
|
||||
"Flat": "展開",
|
||||
@@ -254,7 +254,7 @@
|
||||
"Sketch plane has no body-face attachment.": "スケッチ平面にボディ面のアタッチメントがありません。",
|
||||
"Sketch points": "スケッチ点",
|
||||
"Slot width": "長穴の幅",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "これらのデザインが保存される前にSolidCraftが終了しました。最後の自動保存ファイルを開くことができます。",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "これらのデザインが保存される前にOneCADが終了しました。最後の自動保存ファイルを開くことができます。",
|
||||
"Speed": "速度",
|
||||
"Stand it up": "正立させる",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "正立させる: 取り消し可能な1つの移動フィーチャーとして、X軸周りに90度回転します。",
|
||||
@@ -697,18 +697,18 @@
|
||||
"home.report_issue": "問題を報告",
|
||||
"home.samples": "サンプル",
|
||||
"home.whats_new": "新機能",
|
||||
"install.button_tip": "SolidCraft をアプリとしてインストールします。専用のウィンドウで開き、オフラインでも使えます",
|
||||
"install.button_tip": "OneCAD をアプリとしてインストールします。専用のウィンドウで開き、オフラインでも使えます",
|
||||
"install.dock_1": "「ファイル」›「Dockに追加」を選びます。",
|
||||
"install.dock_2": "「追加」をクリックします。",
|
||||
"install.dock_after": "SolidCraft は Dock から専用のウィンドウで開き、オフラインでも使えます。",
|
||||
"install.dock_after": "OneCAD は Dock から専用のウィンドウで開き、オフラインでも使えます。",
|
||||
"install.done": "完了",
|
||||
"install.home_screen_1": "「共有」をタップします(ツールバーにない場合は … メニューの中にあります)。",
|
||||
"install.home_screen_2": "「ホーム画面に追加」を選びます。",
|
||||
"install.home_screen_3": "「追加」をタップします。",
|
||||
"install.home_screen_after": "SolidCraft はホーム画面のアイコンから Safari のツールバーなしで開き、オフラインでも使えます。",
|
||||
"install.home_screen_after": "OneCAD はホーム画面のアイコンから Safari のツールバーなしで開き、オフラインでも使えます。",
|
||||
"install.menu": "アプリをインストール…",
|
||||
"install.steps_intro": "Safari では、Web アプリを Safari 自身のメニューからインストールします:",
|
||||
"install.title": "SolidCraft をインストール",
|
||||
"install.title": "OneCAD をインストール",
|
||||
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。",
|
||||
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
|
||||
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
|
||||
@@ -792,10 +792,10 @@
|
||||
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。オンの間、ビューは常に再描画されます。",
|
||||
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
|
||||
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
|
||||
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
|
||||
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 OneCAD を起動したときに適用されます",
|
||||
"settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。",
|
||||
"settings.msaa_unsupported": "このグラフィックスデバイスではサポートされていません",
|
||||
"settings.reset_intro": "次の設定はこのコンピューターでは使用できず、SolidCraft が起動できなくなるため変更されました:",
|
||||
"settings.reset_intro": "次の設定はこのコンピューターでは使用できず、OneCAD が起動できなくなるため変更されました:",
|
||||
"settings.reset_title": "設定が変更されました",
|
||||
"settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。",
|
||||
"sketch.degrees_of_freedom": "自由度 {count}",
|
||||
@@ -837,7 +837,7 @@
|
||||
"ui.3d_mouse_speed": "3D マウス速度",
|
||||
"ui.3d_mouse_windows": "3D マウス",
|
||||
"ui.3d_sketch": "3D スケッチ",
|
||||
"ui.about": "SolidCraft について",
|
||||
"ui.about": "OneCAD について",
|
||||
"ui.activate": "アクティブ化",
|
||||
"ui.active_component": "アクティブなコンポーネント",
|
||||
"ui.agent_chat": "エージェントチャット",
|
||||
@@ -848,8 +848,8 @@
|
||||
"ui.agent_chat_attach_view": "ビューを添付",
|
||||
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
|
||||
"ui.agent_chat_auth_agent": "{name} でサインイン",
|
||||
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
|
||||
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
|
||||
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して OneCAD を再起動してください。",
|
||||
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。OneCAD がパスワードやキーを見ることはありません。",
|
||||
"ui.agent_chat_auth_none": "このエージェントはここでのサインイン方法を提示していません。エージェントのドキュメントに従ってサインインしてください。",
|
||||
"ui.agent_chat_auth_retry": "サインインしました: 再接続",
|
||||
"ui.agent_chat_auth_terminal": "ターミナルでこれを実行してから、もう一度「接続」を押してください:",
|
||||
@@ -866,12 +866,13 @@
|
||||
"ui.agent_chat_deny": "拒否",
|
||||
"ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。",
|
||||
"ui.agent_chat_detecting": "エージェントを検索中…",
|
||||
"ui.agent_chat_dock": "サイドパネルに戻す",
|
||||
"ui.agent_chat_dock_left": "左にドッキング",
|
||||
"ui.agent_chat_dock_right": "右にドッキング",
|
||||
"ui.agent_chat_edit": "編集",
|
||||
"ui.agent_chat_exited": "エージェントが停止しました。",
|
||||
"ui.agent_chat_failed_start": "エージェントを起動できませんでした: {error}",
|
||||
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
|
||||
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。OneCAD のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
|
||||
"ui.agent_chat_key_in_env": "キー設定済み",
|
||||
"ui.agent_chat_left": "左",
|
||||
"ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。",
|
||||
@@ -887,12 +888,14 @@
|
||||
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
|
||||
"ui.agent_chat_not_installed": "未インストール",
|
||||
"ui.agent_chat_not_signed_in": "未サインイン",
|
||||
"ui.agent_chat_own_window": "独立したウィンドウ",
|
||||
"ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}",
|
||||
"ui.agent_chat_pick": "エージェント",
|
||||
"ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…",
|
||||
"ui.agent_chat_placeholder_busy": "エージェントの作業が終わったら送るメッセージを入力(Ctrl+Enter: 今すぐ送信)",
|
||||
"ui.agent_chat_pop_out": "独立したウィンドウで開く(別の画面に移動できます)",
|
||||
"ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。",
|
||||
"ui.agent_chat_privacy_note": "OneCAD はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。OneCAD がキーやパスワードを見たり保存したりすることはありません。",
|
||||
"ui.agent_chat_queue": "待機に追加",
|
||||
"ui.agent_chat_queue_full": "待機中のメッセージが多すぎます。削除するか、エージェントの完了をお待ちください。",
|
||||
"ui.agent_chat_queued": "待機中",
|
||||
@@ -931,7 +934,7 @@
|
||||
"ui.ai_add_installed": "インストール済みエージェント",
|
||||
"ui.ai_add_installed_note": "インストール済みエージェントの独自エントリ(例: 別の引数で起動)。インストール済みのエージェントは自動で一覧に表示されます。",
|
||||
"ui.ai_added": "追加済み",
|
||||
"ui.ai_agent_mode_tip": "エージェント自身のモード: エージェント自身のツール(シェル、ファイル編集)はこれに従います。SolidCraft のツールは「環境設定 > AI」の権限に従います。",
|
||||
"ui.ai_agent_mode_tip": "エージェント自身のモード: エージェント自身のツール(シェル、ファイル編集)はこれに従います。OneCAD のツールは「環境設定 > AI」の権限に従います。",
|
||||
"ui.ai_agents": "エージェント",
|
||||
"ui.ai_always_allow": "常に許可",
|
||||
"ui.ai_always_allow_tip": "今後このエージェントに {tool} を確認なしで許可します",
|
||||
@@ -939,7 +942,7 @@
|
||||
"ui.ai_always_allowed_none": "まだ常に許可されたものはありません。チャットで「常に許可」と答えたものがここに表示されます。",
|
||||
"ui.ai_auto_allowed": "自動許可: {title}({why})",
|
||||
"ui.ai_auto_mode": "権限の設定",
|
||||
"ui.ai_auto_ours": "SolidCraft のツールは SolidCraft 自身が確認します",
|
||||
"ui.ai_auto_ours": "OneCAD のツールは OneCAD 自身が確認します",
|
||||
"ui.ai_auto_remembered": "「常に許可」を選択済み",
|
||||
"ui.ai_bad_url": "http:// または https:// で始まる URL を入力してください",
|
||||
"ui.ai_base_url": "ベース URL",
|
||||
@@ -953,7 +956,7 @@
|
||||
"ui.ai_key": "API キー",
|
||||
"ui.ai_key_hint": "任意。システムのキーチェーンに保存",
|
||||
"ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。",
|
||||
"ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
|
||||
"ui.ai_key_note": "システムのキーチェーンに保存され、OneCAD の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
|
||||
"ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}",
|
||||
"ui.ai_mode_all": "すべて許可",
|
||||
"ui.ai_mode_all_note": "確認なしで実行します。クラッシュレポートは引き続き対象外で、フォルダー制限も有効です。",
|
||||
@@ -964,7 +967,7 @@
|
||||
"ui.ai_mode_edits_note": "モデリングコマンドは確認なしで実行されます。保存・開く・エクスポートなどのファイル操作や破壊的な操作は引き続き確認します。",
|
||||
"ui.ai_mode_global": "上の設定に従う",
|
||||
"ui.ai_model": "モデル",
|
||||
"ui.ai_modes_scope": "これらの設定は SolidCraft のツールに適用されます。エージェント自身のツール(シェル、ファイル編集)は、エージェントにモードがある場合にチャットパネルのヘッダーで選ぶエージェント自身のモードに従います。",
|
||||
"ui.ai_modes_scope": "これらの設定は OneCAD のツールに適用されます。エージェント自身のツール(シェル、ファイル編集)は、エージェントにモードがある場合にチャットパネルのヘッダーで選ぶエージェント自身のモードに従います。",
|
||||
"ui.ai_name": "名前",
|
||||
"ui.ai_need_command": "エージェントを起動するコマンドを入力してください",
|
||||
"ui.ai_need_model": "モデル名を入力してください",
|
||||
@@ -1494,31 +1497,31 @@
|
||||
"ui.zoom": "ズーム",
|
||||
"ui.zoom_to_cursor_tip": "マウスホイールでカーソル位置を基準にズームします(オフの場合はビューの中心を基準にズームします)。",
|
||||
"ui.zoom_to_the_cursor": "カーソル位置へズーム",
|
||||
"update.available": "SolidCraft {version} が利用できます",
|
||||
"update.available": "OneCAD {version} が利用できます",
|
||||
"update.button": "アップデート",
|
||||
"update.button_tip": "SolidCraft {version} が利用できます",
|
||||
"update.button_tip": "OneCAD {version} が利用できます",
|
||||
"update.button_version": "{version} にアップデート",
|
||||
"update.check_automatically": "アップデートを自動的に確認",
|
||||
"update.check_now": "今すぐ確認",
|
||||
"update.checking": "確認中…",
|
||||
"update.copy": "コピー",
|
||||
"update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。",
|
||||
"update.dmg_opened": "ディスクイメージを検証して開きました: OneCAD をアプリケーションフォルダにドラッグして古いものを置き換え、OneCAD を再起動してください。",
|
||||
"update.download": "ダウンロード",
|
||||
"update.download_open": "ダウンロードして開く",
|
||||
"update.downloading": "ダウンロードと検証中… {done} / {total} MB",
|
||||
"update.failed": "確認できませんでした: {error}",
|
||||
"update.file": "ダウンロードするファイル: {file}",
|
||||
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。",
|
||||
"update.install_msi": "インストール (SolidCraft を終了)",
|
||||
"update.installed": "SolidCraft {version} をインストールしました。再起動すると使えます。",
|
||||
"update.installer_started": "SolidCraft の終了後にインストーラーが起動します。",
|
||||
"update.install_msi": "インストール (OneCAD を終了)",
|
||||
"update.installed": "OneCAD {version} をインストールしました。再起動すると使えます。",
|
||||
"update.installer_started": "OneCAD の終了後にインストーラーが起動します。",
|
||||
"update.later": "後で通知",
|
||||
"update.no_notes": "このリリースにはリリースノートがありません。",
|
||||
"update.package_hint": "このコピーは {kind} からインストールされました。新しいパッケージをダウンロードし、次のコマンドでインストールします:",
|
||||
"update.prefs_dev": "これは開発ビルド ({version}) のため、アップデートを確認しません。",
|
||||
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。SolidCraft 自身は確認しません。",
|
||||
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。OneCAD 自身は確認しません。",
|
||||
"update.prefs_label": "アップデート",
|
||||
"update.prefs_note": "これは SolidCraft {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
|
||||
"update.prefs_note": "これは OneCAD {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
|
||||
"update.release_page": "リリースページを開く",
|
||||
"update.restart": "今すぐ再起動",
|
||||
"update.restart_later": "後で",
|
||||
@@ -1526,7 +1529,7 @@
|
||||
"update.skip": "このバージョンをスキップ",
|
||||
"update.system_hint": "このコピーは {kind} です。次のコマンドで更新します:",
|
||||
"update.title": "アップデートがあります",
|
||||
"update.up_to_date": "SolidCraft は最新です。",
|
||||
"update.up_to_date": "OneCAD は最新です。",
|
||||
"update.update_now": "今すぐ更新",
|
||||
"update.you_have": "現在のバージョンは {version} です。"
|
||||
}
|
||||
+23
-23
@@ -486,18 +486,18 @@
|
||||
"home.report_issue": "문제 신고",
|
||||
"home.samples": "샘플",
|
||||
"home.whats_new": "새로운 기능",
|
||||
"install.button_tip": "SolidCraft를 앱으로 설치합니다. 별도 창에서 열리며 오프라인에서도 작동합니다",
|
||||
"install.button_tip": "OneCAD를 앱으로 설치합니다. 별도 창에서 열리며 오프라인에서도 작동합니다",
|
||||
"install.dock_1": "파일 › Dock에 추가를 선택합니다.",
|
||||
"install.dock_2": "추가를 클릭합니다.",
|
||||
"install.dock_after": "이후 SolidCraft는 Dock에서 별도 창으로 열리며 오프라인에서도 작동합니다.",
|
||||
"install.dock_after": "이후 OneCAD는 Dock에서 별도 창으로 열리며 오프라인에서도 작동합니다.",
|
||||
"install.done": "완료",
|
||||
"install.home_screen_1": "공유를 탭합니다(도구 막대에 없으면 … 메뉴 안에 있습니다).",
|
||||
"install.home_screen_2": "홈 화면에 추가를 선택합니다.",
|
||||
"install.home_screen_3": "추가를 탭합니다.",
|
||||
"install.home_screen_after": "이후 SolidCraft는 홈 화면의 아이콘에서 Safari 도구 막대 없이 열리며 오프라인에서도 작동합니다.",
|
||||
"install.home_screen_after": "이후 OneCAD는 홈 화면의 아이콘에서 Safari 도구 막대 없이 열리며 오프라인에서도 작동합니다.",
|
||||
"install.menu": "앱 설치…",
|
||||
"install.steps_intro": "Safari는 자체 메뉴에서 웹 앱을 설치합니다:",
|
||||
"install.title": "SolidCraft 설치",
|
||||
"install.title": "OneCAD 설치",
|
||||
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.",
|
||||
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
|
||||
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
|
||||
@@ -572,10 +572,10 @@
|
||||
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다. 켜져 있는 동안 뷰를 계속 다시 그립니다.",
|
||||
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
|
||||
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
|
||||
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
|
||||
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 OneCAD를 시작할 때 적용됩니다",
|
||||
"settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).",
|
||||
"settings.msaa_unsupported": "이 그래픽 장치에서 지원되지 않음",
|
||||
"settings.reset_intro": "다음 설정은 이 컴퓨터에서 작동하지 않아 SolidCraft를 시작할 수 없게 하므로 변경되었습니다:",
|
||||
"settings.reset_intro": "다음 설정은 이 컴퓨터에서 작동하지 않아 OneCAD를 시작할 수 없게 하므로 변경되었습니다:",
|
||||
"settings.reset_title": "설정이 변경되었습니다",
|
||||
"settings.supersampling_note": "뷰를 더 높은 해상도로 그린 뒤 축소합니다. 4K 미만 화면에서 모서리, 선, 그리드가 더 매끄러워집니다. GPU 시간이 듭니다: 1.5×에서 약 2.25배, 2×에서 4배의 픽셀.",
|
||||
"sketch.degrees_of_freedom": "자유도 {count}개",
|
||||
@@ -618,8 +618,8 @@
|
||||
"ui.agent_chat_attach_view": "뷰 첨부",
|
||||
"ui.agent_chat_attach_view_tip": "현재 뷰의 이미지를 메시지와 함께 보냅니다",
|
||||
"ui.agent_chat_auth_agent": "{name}(으)로 로그인",
|
||||
"ui.agent_chat_auth_env": "환경 변수 {vars}을(를) 설정하고 SolidCraft를 다시 시작하세요.",
|
||||
"ui.agent_chat_auth_needed": "에이전트가 로그인을 요청합니다. 로그인은 에이전트에서 직접 하며, SolidCraft는 비밀번호나 키를 보지 않습니다.",
|
||||
"ui.agent_chat_auth_env": "환경 변수 {vars}을(를) 설정하고 OneCAD를 다시 시작하세요.",
|
||||
"ui.agent_chat_auth_needed": "에이전트가 로그인을 요청합니다. 로그인은 에이전트에서 직접 하며, OneCAD는 비밀번호나 키를 보지 않습니다.",
|
||||
"ui.agent_chat_auth_none": "이 에이전트는 여기서 로그인 방법을 제공하지 않습니다. 에이전트 문서에 안내된 방법으로 로그인하세요.",
|
||||
"ui.agent_chat_auth_retry": "로그인했습니다: 다시 연결",
|
||||
"ui.agent_chat_auth_terminal": "터미널에서 이것을 실행한 뒤 다시 연결을 누르세요:",
|
||||
@@ -640,7 +640,7 @@
|
||||
"ui.agent_chat_dock_right": "오른쪽에 고정",
|
||||
"ui.agent_chat_exited": "에이전트가 중지되었습니다.",
|
||||
"ui.agent_chat_failed_start": "에이전트를 시작할 수 없습니다: {error}",
|
||||
"ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. SolidCraft 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.",
|
||||
"ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. OneCAD 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.",
|
||||
"ui.agent_chat_key_in_env": "키 설정됨",
|
||||
"ui.agent_chat_left": "왼쪽",
|
||||
"ui.agent_chat_limit": "에이전트가 메시지당 변경 한도 {limit}에 도달했습니다. 계속하게 하려면 메시지를 하나 더 보내세요.",
|
||||
@@ -656,7 +656,7 @@
|
||||
"ui.agent_chat_pick": "에이전트",
|
||||
"ui.agent_chat_placeholder": "에이전트에게 디자인 변경을 요청하세요…",
|
||||
"ui.agent_chat_privacy": "사용자의 계정으로 실행하는 에이전트 외에는 아무 데도 전송되지 않습니다.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft는 아무것도 어디로도 보내지 않습니다. 실행한 에이전트가 사용자의 계정으로 자신의 서비스와 통신합니다. SolidCraft는 키나 비밀번호를 보거나 저장하지 않습니다.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD는 아무것도 어디로도 보내지 않습니다. 실행한 에이전트가 사용자의 계정으로 자신의 서비스와 통신합니다. OneCAD는 키나 비밀번호를 보거나 저장하지 않습니다.",
|
||||
"ui.agent_chat_refresh": "새로 고침",
|
||||
"ui.agent_chat_refused": "에이전트에게 허용되지 않음: {reason}",
|
||||
"ui.agent_chat_right": "오른쪽",
|
||||
@@ -686,7 +686,7 @@
|
||||
"ui.ai_add_installed": "설치된 에이전트",
|
||||
"ui.ai_add_installed_note": "설치된 에이전트에 대한 별도 항목(예: 다른 인수로 실행). 설치된 에이전트는 자동으로 표시됩니다.",
|
||||
"ui.ai_added": "추가됨",
|
||||
"ui.ai_agent_mode_tip": "에이전트 자체 모드: 에이전트 자체 도구(셸, 파일 편집)는 이 모드를 따릅니다. SolidCraft 도구는 환경 설정 > AI의 권한을 따릅니다.",
|
||||
"ui.ai_agent_mode_tip": "에이전트 자체 모드: 에이전트 자체 도구(셸, 파일 편집)는 이 모드를 따릅니다. OneCAD 도구는 환경 설정 > AI의 권한을 따릅니다.",
|
||||
"ui.ai_agents": "에이전트",
|
||||
"ui.ai_always_allow": "항상 허용",
|
||||
"ui.ai_always_allow_tip": "이 에이전트에게 앞으로 {tool}을(를) 묻지 않고 허용",
|
||||
@@ -694,7 +694,7 @@
|
||||
"ui.ai_always_allowed_none": "아직 항상 허용된 것이 없습니다. 채팅에서 \"항상 허용\"으로 답한 항목이 여기에 표시됩니다.",
|
||||
"ui.ai_auto_allowed": "자동 허용: {title} ({why})",
|
||||
"ui.ai_auto_mode": "권한 설정",
|
||||
"ui.ai_auto_ours": "SolidCraft가 자체 도구를 직접 확인합니다",
|
||||
"ui.ai_auto_ours": "OneCAD가 자체 도구를 직접 확인합니다",
|
||||
"ui.ai_auto_remembered": "\"항상 허용\"을 선택함",
|
||||
"ui.ai_bad_url": "http:// 또는 https://로 시작하는 URL을 입력하세요",
|
||||
"ui.ai_base_url": "기본 URL",
|
||||
@@ -708,7 +708,7 @@
|
||||
"ui.ai_key": "API 키",
|
||||
"ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장",
|
||||
"ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.",
|
||||
"ui.ai_key_note": "SolidCraft 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.",
|
||||
"ui.ai_key_note": "OneCAD 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.",
|
||||
"ui.ai_keychain_error": "시스템 키체인이 거부했습니다: {error}",
|
||||
"ui.ai_mode_all": "모두 허용",
|
||||
"ui.ai_mode_all_note": "묻지 않고 실행합니다. 충돌 보고서는 계속 제외되고 폴더 제한도 그대로 적용됩니다.",
|
||||
@@ -719,7 +719,7 @@
|
||||
"ui.ai_mode_edits_note": "모델링 명령은 묻지 않고 실행되며, 저장·열기·내보내기 등 파일 작업이나 파괴적인 작업은 계속 묻습니다.",
|
||||
"ui.ai_mode_global": "위 설정 따름",
|
||||
"ui.ai_model": "모델",
|
||||
"ui.ai_modes_scope": "이 설정은 SolidCraft 도구에 적용됩니다. 에이전트 자체 도구(셸, 파일 편집)는 에이전트에 모드가 있을 때 채팅 패널 머리글에서 고르는 에이전트 자체 모드를 따릅니다.",
|
||||
"ui.ai_modes_scope": "이 설정은 OneCAD 도구에 적용됩니다. 에이전트 자체 도구(셸, 파일 편집)는 에이전트에 모드가 있을 때 채팅 패널 머리글에서 고르는 에이전트 자체 모드를 따릅니다.",
|
||||
"ui.ai_name": "이름",
|
||||
"ui.ai_need_command": "에이전트를 시작하는 명령을 입력하세요",
|
||||
"ui.ai_need_model": "모델 이름을 입력하세요",
|
||||
@@ -1202,31 +1202,31 @@
|
||||
"ui.y_distance": "Y 거리",
|
||||
"ui.zoom": "확대/축소",
|
||||
"ui.zoom_to_the_cursor": "커서 위치로 확대/축소",
|
||||
"update.available": "SolidCraft {version} 사용 가능",
|
||||
"update.available": "OneCAD {version} 사용 가능",
|
||||
"update.button": "업데이트",
|
||||
"update.button_tip": "SolidCraft {version} 사용 가능",
|
||||
"update.button_tip": "OneCAD {version} 사용 가능",
|
||||
"update.button_version": "{version}(으)로 업데이트",
|
||||
"update.check_automatically": "업데이트 자동 확인",
|
||||
"update.check_now": "지금 확인",
|
||||
"update.checking": "확인 중…",
|
||||
"update.copy": "복사",
|
||||
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
|
||||
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: OneCAD를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 OneCAD를 다시 시작하세요.",
|
||||
"update.download": "다운로드",
|
||||
"update.download_open": "다운로드 후 열기",
|
||||
"update.downloading": "다운로드 및 확인 중… {done} / {total} MB",
|
||||
"update.failed": "확인할 수 없습니다: {error}",
|
||||
"update.file": "내려받을 파일: {file}",
|
||||
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다.",
|
||||
"update.install_msi": "설치 (SolidCraft 종료)",
|
||||
"update.installed": "SolidCraft {version}이(가) 설치되었습니다. 다시 시작하면 사용할 수 있습니다.",
|
||||
"update.installer_started": "SolidCraft가 종료되면 설치 프로그램이 시작됩니다.",
|
||||
"update.install_msi": "설치 (OneCAD 종료)",
|
||||
"update.installed": "OneCAD {version}이(가) 설치되었습니다. 다시 시작하면 사용할 수 있습니다.",
|
||||
"update.installer_started": "OneCAD가 종료되면 설치 프로그램이 시작됩니다.",
|
||||
"update.later": "나중에 알림",
|
||||
"update.no_notes": "이 릴리스에는 릴리스 노트가 없습니다.",
|
||||
"update.package_hint": "이 사본은 {kind}(으)로 설치되었습니다. 새 패키지를 내려받은 뒤 다음 명령으로 설치하세요:",
|
||||
"update.prefs_dev": "개발 빌드({version})이므로 업데이트를 확인하지 않습니다.",
|
||||
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. SolidCraft는 직접 확인하지 않습니다.",
|
||||
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. OneCAD는 직접 확인하지 않습니다.",
|
||||
"update.prefs_label": "업데이트",
|
||||
"update.prefs_note": "SolidCraft {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
|
||||
"update.prefs_note": "OneCAD {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
|
||||
"update.release_page": "릴리스 페이지 열기",
|
||||
"update.restart": "지금 다시 시작",
|
||||
"update.restart_later": "나중에",
|
||||
@@ -1234,7 +1234,7 @@
|
||||
"update.skip": "이 버전 건너뛰기",
|
||||
"update.system_hint": "이 사본은 {kind}입니다. 다음 명령으로 업데이트하세요:",
|
||||
"update.title": "업데이트 사용 가능",
|
||||
"update.up_to_date": "SolidCraft가 최신 버전입니다.",
|
||||
"update.up_to_date": "OneCAD가 최신 버전입니다.",
|
||||
"update.update_now": "지금 업데이트",
|
||||
"update.you_have": "현재 버전은 {version}입니다."
|
||||
}
|
||||
+25
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Clique em Submit new issue no GitHub para enviar o relatório.",
|
||||
"A new configuration, a copy of the active one": "Uma nova configuração, cópia da ativa",
|
||||
"AI": "IA",
|
||||
"About SolidCraft": "Sobre o SolidCraft",
|
||||
"About OneCAD": "Sobre o OneCAD",
|
||||
"Active": "Ativo",
|
||||
"Active rule": "Regra ativa",
|
||||
"Add": "Adicionar",
|
||||
@@ -239,7 +239,7 @@
|
||||
"Sketch plane has no body-face attachment.": "O plano do esboço não está fixado a uma face de corpo.",
|
||||
"Sketch points": "Pontos do esboço",
|
||||
"Slot width": "Largura do rasgo",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "O SolidCraft fechou antes de estes projetos serem salvos. O último salvamento automático deles pode ser aberto.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "O OneCAD fechou antes de estes projetos serem salvos. O último salvamento automático deles pode ser aberto.",
|
||||
"Speed": "Velocidade",
|
||||
"Stand it up": "Colocar em pé",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Colocar em pé: um quarto de volta em torno de X, como um recurso Mover que pode ser desfeito.",
|
||||
@@ -653,18 +653,18 @@
|
||||
"home.report_issue": "Relatar um problema",
|
||||
"home.samples": "Exemplos",
|
||||
"home.whats_new": "Novidades",
|
||||
"install.button_tip": "Instale o SolidCraft como aplicativo: ele abre em sua própria janela e funciona offline",
|
||||
"install.button_tip": "Instale o OneCAD como aplicativo: ele abre em sua própria janela e funciona offline",
|
||||
"install.dock_1": "Escolha Arquivo › Adicionar ao Dock.",
|
||||
"install.dock_2": "Clique em Adicionar.",
|
||||
"install.dock_after": "O SolidCraft passa a abrir em sua própria janela pelo Dock e funciona offline.",
|
||||
"install.dock_after": "O OneCAD passa a abrir em sua própria janela pelo Dock e funciona offline.",
|
||||
"install.done": "Concluído",
|
||||
"install.home_screen_1": "Toque em Compartilhar (no menu … se não estiver na barra de ferramentas).",
|
||||
"install.home_screen_2": "Escolha Adicionar à Tela de Início.",
|
||||
"install.home_screen_3": "Toque em Adicionar.",
|
||||
"install.home_screen_after": "O SolidCraft passa a abrir pelo ícone na Tela de Início, sem as barras do Safari, e funciona offline.",
|
||||
"install.home_screen_after": "O OneCAD passa a abrir pelo ícone na Tela de Início, sem as barras do Safari, e funciona offline.",
|
||||
"install.menu": "Instalar aplicativo…",
|
||||
"install.steps_intro": "O Safari instala aplicativos web pelos próprios menus:",
|
||||
"install.title": "Instalar o SolidCraft",
|
||||
"install.title": "Instalar o OneCAD",
|
||||
"joint.from_bodies_hint": "Cada corpo selecionado vai para um novo componente próprio e permanece onde está.",
|
||||
"joint.one_body": "As duas seleções estão em {body}. Selecione a segunda em outra peça.",
|
||||
"joint.one_component": "Estas peças são corpos de um mesmo componente: {bodies}. Uma junta move um componente em relação a outro, então cada peça precisa de seu próprio componente.",
|
||||
@@ -744,10 +744,10 @@
|
||||
"settings.frame_rate_note": "Mostra quadros por segundo no canto inferior direito da vista. Enquanto estiver ativo, a vista é redesenhada continuamente.",
|
||||
"settings.measure_axis_distances": "Mostrar distâncias X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostrar as componentes nos eixos do mundo junto com a distância total enquanto Medir está aberto.",
|
||||
"settings.msaa_applies_on_restart": "O antisserrilhamento vale na próxima vez que o SolidCraft iniciar",
|
||||
"settings.msaa_applies_on_restart": "O antisserrilhamento vale na próxima vez que o OneCAD iniciar",
|
||||
"settings.msaa_reset": "{setting}: {requested}× não é suportado por este dispositivo gráfico; agora é {reset}× (suportados: {supported}).",
|
||||
"settings.msaa_unsupported": "Não suportado por este dispositivo gráfico",
|
||||
"settings.reset_intro": "Estas configurações não funcionam neste computador e impediriam o SolidCraft de iniciar, por isso foram alteradas:",
|
||||
"settings.reset_intro": "Estas configurações não funcionam neste computador e impediriam o OneCAD de iniciar, por isso foram alteradas:",
|
||||
"settings.reset_title": "As configurações foram alteradas",
|
||||
"settings.supersampling_note": "Desenha a vista em resolução maior e a filtra para baixo: arestas, linhas e grade mais suaves em telas abaixo de 4K. Custa tempo de GPU: cerca de 2,25× os pixels em 1,5× e 4× em 2×.",
|
||||
"sketch.degrees_of_freedom": "{count} graus de liberdade",
|
||||
@@ -794,8 +794,8 @@
|
||||
"ui.agent_chat_attach_view": "Anexar vista",
|
||||
"ui.agent_chat_attach_view_tip": "Enviar uma imagem da vista atual com a mensagem",
|
||||
"ui.agent_chat_auth_agent": "Entrar com {name}",
|
||||
"ui.agent_chat_auth_env": "Defina {vars} no seu ambiente e reinicie o SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "O agente pede que você entre. Você entra no próprio agente; o SolidCraft nunca vê sua senha ou chaves.",
|
||||
"ui.agent_chat_auth_env": "Defina {vars} no seu ambiente e reinicie o OneCAD.",
|
||||
"ui.agent_chat_auth_needed": "O agente pede que você entre. Você entra no próprio agente; o OneCAD nunca vê sua senha ou chaves.",
|
||||
"ui.agent_chat_auth_none": "O agente não oferece forma de entrar aqui: entre do jeito descrito na documentação dele.",
|
||||
"ui.agent_chat_auth_retry": "Já entrei: conectar de novo",
|
||||
"ui.agent_chat_auth_terminal": "Execute isto em um terminal e depois pressione Conectar de novo:",
|
||||
@@ -817,7 +817,7 @@
|
||||
"ui.agent_chat_edit": "Editar",
|
||||
"ui.agent_chat_exited": "O agente parou.",
|
||||
"ui.agent_chat_failed_start": "O agente não pôde iniciar: {error}",
|
||||
"ui.agent_chat_intro": "Converse com um agente de programação que você já usa, ou com um endpoint de modelo que você escolher. Ele trabalha no projeto aberto pelas ferramentas do SolidCraft: as alterações aparecem conforme são feitas e se desfazem em um passo por mensagem. O que ele pode fazer sem perguntar é definido em Preferências > IA.",
|
||||
"ui.agent_chat_intro": "Converse com um agente de programação que você já usa, ou com um endpoint de modelo que você escolher. Ele trabalha no projeto aberto pelas ferramentas do OneCAD: as alterações aparecem conforme são feitas e se desfazem em um passo por mensagem. O que ele pode fazer sem perguntar é definido em Preferências > IA.",
|
||||
"ui.agent_chat_key_in_env": "chave definida",
|
||||
"ui.agent_chat_left": "Esquerda",
|
||||
"ui.agent_chat_limit": "O agente atingiu o limite de {limit} alterações por mensagem. Envie outra mensagem para que ele continue.",
|
||||
@@ -838,7 +838,7 @@
|
||||
"ui.agent_chat_placeholder": "Peça ao agente para alterar o projeto…",
|
||||
"ui.agent_chat_placeholder_busy": "Enfileirar uma mensagem para quando o agente terminar (Ctrl+Enter: enviar agora)",
|
||||
"ui.agent_chat_privacy": "Nada vai para lugar algum além do agente que você executa, na sua própria conta.",
|
||||
"ui.agent_chat_privacy_note": "O SolidCraft não envia nada a lugar nenhum: o agente que você executa fala com o próprio serviço, na sua própria conta. O SolidCraft nunca vê nem guarda chaves ou senhas.",
|
||||
"ui.agent_chat_privacy_note": "O OneCAD não envia nada a lugar nenhum: o agente que você executa fala com o próprio serviço, na sua própria conta. O OneCAD nunca vê nem guarda chaves ou senhas.",
|
||||
"ui.agent_chat_queue": "Fila",
|
||||
"ui.agent_chat_queue_full": "Há mensagens demais esperando: remova uma ou aguarde o agente.",
|
||||
"ui.agent_chat_queued": "Na fila",
|
||||
@@ -877,7 +877,7 @@
|
||||
"ui.ai_add_installed": "Agente instalado",
|
||||
"ui.ai_add_installed_note": "Uma entrada própria para um agente instalado, por exemplo com outros argumentos. Os agentes instalados são listados por si mesmos.",
|
||||
"ui.ai_added": "adicionado",
|
||||
"ui.ai_agent_mode_tip": "O modo do próprio agente: as ferramentas dele (shell, edição de arquivos) o seguem. As ferramentas do SolidCraft seguem as permissões em Preferências > IA.",
|
||||
"ui.ai_agent_mode_tip": "O modo do próprio agente: as ferramentas dele (shell, edição de arquivos) o seguem. As ferramentas do OneCAD seguem as permissões em Preferências > IA.",
|
||||
"ui.ai_agents": "Agentes",
|
||||
"ui.ai_always_allow": "Sempre permitir",
|
||||
"ui.ai_always_allow_tip": "Permitir {tool} para este agente daqui em diante, sem perguntar",
|
||||
@@ -885,7 +885,7 @@
|
||||
"ui.ai_always_allowed_none": "Nada é sempre permitido ainda. As respostas \"Sempre permitir\" que você der no chat aparecem aqui.",
|
||||
"ui.ai_auto_allowed": "Permitido automaticamente: {title} ({why})",
|
||||
"ui.ai_auto_mode": "sua configuração de permissão",
|
||||
"ui.ai_auto_ours": "o SolidCraft verifica as próprias ferramentas",
|
||||
"ui.ai_auto_ours": "o OneCAD verifica as próprias ferramentas",
|
||||
"ui.ai_auto_remembered": "você escolheu Sempre permitir",
|
||||
"ui.ai_bad_url": "Digite uma URL que comece com http:// ou https://",
|
||||
"ui.ai_base_url": "URL base",
|
||||
@@ -899,7 +899,7 @@
|
||||
"ui.ai_key": "Chave de API",
|
||||
"ui.ai_key_hint": "opcional; guardada no chaveiro do sistema",
|
||||
"ui.ai_key_needs_https": "Uma chave só é enviada por HTTPS ou para este computador.",
|
||||
"ui.ai_key_note": "Guardada no chaveiro do sistema, nunca nas configurações do SolidCraft. Deixe em branco para um servidor local.",
|
||||
"ui.ai_key_note": "Guardada no chaveiro do sistema, nunca nas configurações do OneCAD. Deixe em branco para um servidor local.",
|
||||
"ui.ai_keychain_error": "O chaveiro do sistema recusou: {error}",
|
||||
"ui.ai_mode_all": "Permitir tudo",
|
||||
"ui.ai_mode_all_note": "Nada pergunta antes. Os relatórios de falha continuam fora de alcance e os limites de pasta ainda valem.",
|
||||
@@ -910,7 +910,7 @@
|
||||
"ui.ai_mode_edits_note": "Comandos de modelagem rodam sem perguntar; salvar, abrir, exportar e outras ações com arquivos ou destrutivas ainda perguntam.",
|
||||
"ui.ai_mode_global": "Como definido acima",
|
||||
"ui.ai_model": "Modelo",
|
||||
"ui.ai_modes_scope": "Estas configurações valem para as ferramentas do SolidCraft. As ferramentas do próprio agente (shell, edição de arquivos) seguem o modo do agente, que você escolhe no cabeçalho do painel de chat quando o agente tem modos.",
|
||||
"ui.ai_modes_scope": "Estas configurações valem para as ferramentas do OneCAD. As ferramentas do próprio agente (shell, edição de arquivos) seguem o modo do agente, que você escolhe no cabeçalho do painel de chat quando o agente tem modos.",
|
||||
"ui.ai_name": "Nome",
|
||||
"ui.ai_need_command": "Digite o comando que inicia o agente",
|
||||
"ui.ai_need_model": "Digite o nome do modelo",
|
||||
@@ -1425,31 +1425,31 @@
|
||||
"ui.zoom": "Zoom",
|
||||
"ui.zoom_to_cursor_tip": "A roda dá zoom em direção ao ponto sob o cursor (desativado: em direção ao centro da vista)",
|
||||
"ui.zoom_to_the_cursor": "Zoom em direção ao cursor",
|
||||
"update.available": "O SolidCraft {version} está disponível",
|
||||
"update.available": "O OneCAD {version} está disponível",
|
||||
"update.button": "Atualizar",
|
||||
"update.button_tip": "O SolidCraft {version} está disponível",
|
||||
"update.button_tip": "O OneCAD {version} está disponível",
|
||||
"update.button_version": "Atualizar para {version}",
|
||||
"update.check_automatically": "Verificar atualizações automaticamente",
|
||||
"update.check_now": "Verificar agora",
|
||||
"update.checking": "Verificando…",
|
||||
"update.copy": "Copiar",
|
||||
"update.dmg_opened": "A imagem de disco está aberta e verificada: arraste o SolidCraft para Aplicativos, substituindo a cópia antiga, e reinicie o SolidCraft.",
|
||||
"update.dmg_opened": "A imagem de disco está aberta e verificada: arraste o OneCAD para Aplicativos, substituindo a cópia antiga, e reinicie o OneCAD.",
|
||||
"update.download": "Baixar",
|
||||
"update.download_open": "Baixar e abrir",
|
||||
"update.downloading": "Baixando e verificando… {done} de {total} MB",
|
||||
"update.failed": "Não foi possível verificar: {error}",
|
||||
"update.file": "Baixar obtém {file}.",
|
||||
"update.install_failed": "A atualização não foi instalada: {error}. Nada foi alterado; Baixar obtém o pacote em vez disso.",
|
||||
"update.install_msi": "Instalar (fecha o SolidCraft)",
|
||||
"update.installed": "O SolidCraft {version} está instalado. Reinicie para usá-lo.",
|
||||
"update.installer_started": "O instalador começa quando o SolidCraft fechar.",
|
||||
"update.install_msi": "Instalar (fecha o OneCAD)",
|
||||
"update.installed": "O OneCAD {version} está instalado. Reinicie para usá-lo.",
|
||||
"update.installer_started": "O instalador começa quando o OneCAD fechar.",
|
||||
"update.later": "Lembrar mais tarde",
|
||||
"update.no_notes": "Esta versão não tem notas.",
|
||||
"update.package_hint": "Esta cópia veio de um {kind}. Baixe o novo pacote e instale-o com:",
|
||||
"update.prefs_dev": "Esta é uma versão de desenvolvimento ({version}): ela não verifica atualizações.",
|
||||
"update.prefs_flatpak": "O Flatpak atualiza esta cópia ({version}): execute {command} ou use sua central de programas. O SolidCraft não verifica.",
|
||||
"update.prefs_flatpak": "O Flatpak atualiza esta cópia ({version}): execute {command} ou use sua central de programas. O OneCAD não verifica.",
|
||||
"update.prefs_label": "Atualizações",
|
||||
"update.prefs_note": "Este é o SolidCraft {version} ({kind}). Uma verificação pergunta ao GitHub pela versão mais recente e não envia mais nada.",
|
||||
"update.prefs_note": "Este é o OneCAD {version} ({kind}). Uma verificação pergunta ao GitHub pela versão mais recente e não envia mais nada.",
|
||||
"update.release_page": "Abrir página da versão",
|
||||
"update.restart": "Reiniciar agora",
|
||||
"update.restart_later": "Depois",
|
||||
@@ -1457,7 +1457,7 @@
|
||||
"update.skip": "Pular esta versão",
|
||||
"update.system_hint": "Esta cópia é um {kind}. Atualize-a com:",
|
||||
"update.title": "Atualização disponível",
|
||||
"update.up_to_date": "O SolidCraft está atualizado.",
|
||||
"update.up_to_date": "O OneCAD está atualizado.",
|
||||
"update.update_now": "Atualizar agora",
|
||||
"update.you_have": "Você tem a {version}."
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"0 turns it off": "0 отключает",
|
||||
"A new configuration, a copy of the active one": "Новая конфигурация — копия активной",
|
||||
"About SolidCraft": "О SolidCraft",
|
||||
"About OneCAD": "О OneCAD",
|
||||
"Acceleration": "Ускорение",
|
||||
"Active": "Активный",
|
||||
"Active rule": "Активное правило",
|
||||
@@ -212,7 +212,7 @@
|
||||
"Sketch geometry and profiles": "Геометрия и профили эскиза",
|
||||
"Sketch plane has no body-face attachment.": "Плоскость эскиза не привязана к грани тела.",
|
||||
"Sketch points": "Точки эскиза",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft закрылся до сохранения этих дизайнов. Можно открыть последнюю автосохранённую версию.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD закрылся до сохранения этих дизайнов. Можно открыть последнюю автосохранённую версию.",
|
||||
"Speed": "Скорость",
|
||||
"Stand it up": "Поставить вертикально",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Поставить вертикально: четверть оборота вокруг X одной операцией «Переместить», которую можно отменить.",
|
||||
@@ -620,18 +620,18 @@
|
||||
"home.report_issue": "Сообщить о проблеме",
|
||||
"home.samples": "Примеры",
|
||||
"home.whats_new": "Что нового",
|
||||
"install.button_tip": "Установить SolidCraft как приложение: отдельное окно и работа без сети",
|
||||
"install.button_tip": "Установить OneCAD как приложение: отдельное окно и работа без сети",
|
||||
"install.dock_1": "Выберите «Файл» › «Добавить в Dock».",
|
||||
"install.dock_2": "Нажмите «Добавить».",
|
||||
"install.dock_after": "После этого SolidCraft открывается из Dock в отдельном окне и работает без сети.",
|
||||
"install.dock_after": "После этого OneCAD открывается из Dock в отдельном окне и работает без сети.",
|
||||
"install.done": "Готово",
|
||||
"install.home_screen_1": "Нажмите «Поделиться» (в меню …, если кнопки нет на панели).",
|
||||
"install.home_screen_2": "Выберите «На экран „Домой“».",
|
||||
"install.home_screen_3": "Нажмите «Добавить».",
|
||||
"install.home_screen_after": "После этого SolidCraft открывается значком на экране «Домой», без панелей Safari, и работает без сети.",
|
||||
"install.home_screen_after": "После этого OneCAD открывается значком на экране «Домой», без панелей Safari, и работает без сети.",
|
||||
"install.menu": "Установить приложение…",
|
||||
"install.steps_intro": "Safari устанавливает веб-приложения через собственные меню:",
|
||||
"install.title": "Установка SolidCraft",
|
||||
"install.title": "Установка OneCAD",
|
||||
"joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте.",
|
||||
"joint.one_body": "Оба выбора на {body}. Выберите второй на другой детали.",
|
||||
"joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.",
|
||||
@@ -707,10 +707,10 @@
|
||||
"sample.vents.name": "Вентиляционная пластина",
|
||||
"settings.measure_axis_distances": "Расстояния X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Показывать размеры вдоль мировых осей вместе с общим расстоянием, пока открыто окно измерения.",
|
||||
"settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске SolidCraft",
|
||||
"settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске OneCAD",
|
||||
"settings.msaa_reset": "{setting}: {requested}× не поддерживается этим графическим устройством; теперь {reset}× (поддерживается: {supported}).",
|
||||
"settings.msaa_unsupported": "Не поддерживается этим графическим устройством",
|
||||
"settings.reset_intro": "Эти настройки не работают на этом компьютере и могли бы помешать запуску SolidCraft, поэтому они были изменены:",
|
||||
"settings.reset_intro": "Эти настройки не работают на этом компьютере и могли бы помешать запуску OneCAD, поэтому они были изменены:",
|
||||
"settings.reset_title": "Настройки были изменены",
|
||||
"sketch.degrees_of_freedom": "Степеней свободы: {count}",
|
||||
"sketch.draw_curves": "Рисовать как {linetype}",
|
||||
|
||||
+25
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Raporu göndermek için GitHub'da Submit new issue düğmesine tıklayın.",
|
||||
"A new configuration, a copy of the active one": "Etkin konfigürasyonun kopyası olan yeni bir konfigürasyon",
|
||||
"AI": "AI",
|
||||
"About SolidCraft": "SolidCraft hakkında",
|
||||
"About OneCAD": "OneCAD hakkında",
|
||||
"Acceleration": "İvme",
|
||||
"Active": "Etkin",
|
||||
"Active rule": "Etkin kural",
|
||||
@@ -230,7 +230,7 @@
|
||||
"Sketch plane has no body-face attachment.": "Eskiz düzleminin bir gövde yüzüne bağlaması yok.",
|
||||
"Sketch points": "Eskiz noktaları",
|
||||
"Slot width": "Yuva genişliği",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft bu tasarımlar kaydedilmeden önce kapandı. Son otomatik kayıtları açılabilir.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD bu tasarımlar kaydedilmeden önce kapandı. Son otomatik kayıtları açılabilir.",
|
||||
"Speed": "Hız",
|
||||
"Stand it up": "Dik çevir",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Dik çevir: X etrafında çeyrek tur; geri alınabilen tek bir Taşı özelliği olarak eklenir.",
|
||||
@@ -619,18 +619,18 @@
|
||||
"home.report_issue": "Sorun bildir",
|
||||
"home.samples": "Örnekler",
|
||||
"home.whats_new": "Yenilikler",
|
||||
"install.button_tip": "SolidCraft'ı uygulama olarak yükleyin: kendi penceresinde açılır ve çevrimdışı da çalışır",
|
||||
"install.button_tip": "OneCAD'ı uygulama olarak yükleyin: kendi penceresinde açılır ve çevrimdışı da çalışır",
|
||||
"install.dock_1": "Dosya › Dock'a Ekle'yi seçin.",
|
||||
"install.dock_2": "“Ekle” düğmesine tıklayın.",
|
||||
"install.dock_after": "SolidCraft daha sonra Dock'tan kendi penceresinde açılır ve çevrimdışı da çalışır.",
|
||||
"install.dock_after": "OneCAD daha sonra Dock'tan kendi penceresinde açılır ve çevrimdışı da çalışır.",
|
||||
"install.done": "Bitti",
|
||||
"install.home_screen_1": "“Paylaş” düğmesine dokunun (araç çubuğunda değilse … menüsünde).",
|
||||
"install.home_screen_2": "“Ana Ekrana Ekle” seçeneğini seçin.",
|
||||
"install.home_screen_3": "“Ekle” düğmesine dokunun.",
|
||||
"install.home_screen_after": "SolidCraft daha sonra ana ekrandaki simgesinden, Safari çubukları olmadan açılır ve çevrimdışı da çalışır.",
|
||||
"install.home_screen_after": "OneCAD daha sonra ana ekrandaki simgesinden, Safari çubukları olmadan açılır ve çevrimdışı da çalışır.",
|
||||
"install.menu": "Uygulamayı yükle…",
|
||||
"install.steps_intro": "Safari web uygulamalarını kendi menüleri üzerinden yükler:",
|
||||
"install.title": "SolidCraft'ı yükle",
|
||||
"install.title": "OneCAD'ı yükle",
|
||||
"joint.from_bodies_hint": "Seçilen her gövde kendi yeni bileşenine konur ve bulunduğu yerde kalır.",
|
||||
"joint.one_body": "Her iki seçim de {body} üzerinde. İkincisini başka bir parçada seçin.",
|
||||
"joint.one_component": "Bu parçalar tek bir bileşenin gövdeleridir: {bodies}. Bir bağlantı bir bileşeni diğerine göre hareket ettirir, bu nedenle her parçanın kendi bileşeni olması gerekir.",
|
||||
@@ -711,10 +711,10 @@
|
||||
"sample.vents.about": "Dikdörtgen desende yuvalar: satırlar, sütunlar ve aralık parametredir",
|
||||
"sample.vents.name": "Havalandırma plakası",
|
||||
"settings.display_quality_note": "Daha ince seviyeler eğri yüzeyleri ve kenarları daha pürüzsüz gösterir, ancak ağ oluşturma daha uzun sürer. Yalnızca görünüm değişir; tasarım ve dışa aktarımlar asla değişmez.",
|
||||
"settings.msaa_applies_on_restart": "Kenar yumuşatma, SolidCraft bir sonraki başlatıldığında uygulanır",
|
||||
"settings.msaa_applies_on_restart": "Kenar yumuşatma, OneCAD bir sonraki başlatıldığında uygulanır",
|
||||
"settings.msaa_reset": "{setting}: {requested}× bu grafik aygıtı tarafından desteklenmiyor; {reset}× olarak ayarlandı (desteklenen: {supported}).",
|
||||
"settings.msaa_unsupported": "Bu grafik aygıtı tarafından desteklenmiyor",
|
||||
"settings.reset_intro": "Bu ayarlar bu bilgisayarda çalışmadığı ve SolidCraft'ın başlamasını engelleyeceği için değiştirildi:",
|
||||
"settings.reset_intro": "Bu ayarlar bu bilgisayarda çalışmadığı ve OneCAD'ın başlamasını engelleyeceği için değiştirildi:",
|
||||
"settings.reset_title": "Ayarlar değiştirildi",
|
||||
"settings.supersampling_note": "Görünümü daha yüksek çözünürlükte çizer ve küçültür: 4K altındaki ekranlarda kenarlar, çizgiler ve ızgaralar daha pürüzsüz görünür. GPU süresi gerektirir: 1,5× ayarında yaklaşık 2,25 kat, 2× ayarında 4 kat daha fazla piksel işlenir.",
|
||||
"sketch.degrees_of_freedom": "{count} serbestlik derecesi",
|
||||
@@ -758,8 +758,8 @@
|
||||
"ui.agent_chat_attach_view": "Görünümü ekle",
|
||||
"ui.agent_chat_attach_view_tip": "Geçerli görünümün bir resmini mesajla birlikte gönder",
|
||||
"ui.agent_chat_auth_agent": "{name} ile oturum aç",
|
||||
"ui.agent_chat_auth_env": "{vars} ortam değişkenlerini ayarlayın ve SolidCraft'ı yeniden başlatın.",
|
||||
"ui.agent_chat_auth_needed": "Ajan oturum açmanızı istiyor. Oturumu ajanın kendisinde açarsınız; SolidCraft parolanızı veya anahtarlarınızı asla görmez.",
|
||||
"ui.agent_chat_auth_env": "{vars} ortam değişkenlerini ayarlayın ve OneCAD'ı yeniden başlatın.",
|
||||
"ui.agent_chat_auth_needed": "Ajan oturum açmanızı istiyor. Oturumu ajanın kendisinde açarsınız; OneCAD parolanızı veya anahtarlarınızı asla görmez.",
|
||||
"ui.agent_chat_auth_none": "Ajan burada oturum açma imkânı sunmuyor: Belgelerinde açıklandığı şekilde oturum açın.",
|
||||
"ui.agent_chat_auth_retry": "Oturum açtım: yeniden bağlan",
|
||||
"ui.agent_chat_auth_terminal": "Bunu bir terminalde çalıştırın ve ardından tekrar Bağlan'a tıklayın:",
|
||||
@@ -780,7 +780,7 @@
|
||||
"ui.agent_chat_dock_right": "Sağa yerleştir",
|
||||
"ui.agent_chat_exited": "Ajan sonlandırıldı.",
|
||||
"ui.agent_chat_failed_start": "Ajan başlatılamadı: {error}",
|
||||
"ui.agent_chat_intro": "Zaten kullandığınız bir kodlama ajanıyla veya seçtiğiniz bir model uç noktasıyla sohbet edin. Ajan, SolidCraft'ın araçlarıyla açık tasarım üzerinde çalışır: Değişiklikleri hemen görünür ve mesaj başına tek adımda geri alınabilir. Sormadan neleri yapabileceğini Ayarlar > AI altında belirlersiniz.",
|
||||
"ui.agent_chat_intro": "Zaten kullandığınız bir kodlama ajanıyla veya seçtiğiniz bir model uç noktasıyla sohbet edin. Ajan, OneCAD'ın araçlarıyla açık tasarım üzerinde çalışır: Değişiklikleri hemen görünür ve mesaj başına tek adımda geri alınabilir. Sormadan neleri yapabileceğini Ayarlar > AI altında belirlersiniz.",
|
||||
"ui.agent_chat_key_in_env": "anahtar ayarlı",
|
||||
"ui.agent_chat_left": "Sol",
|
||||
"ui.agent_chat_limit": "Ajan, bir mesaj için {limit} değişiklik sınırına ulaştı. Devam etmesi için başka bir mesaj gönderin.",
|
||||
@@ -796,7 +796,7 @@
|
||||
"ui.agent_chat_pick": "Ajan",
|
||||
"ui.agent_chat_placeholder": "Ajandan tasarımı değiştirmesini isteyin…",
|
||||
"ui.agent_chat_privacy": "Kendi hesabınızla çalıştırdığınız ajan dışında hiçbir yere bir şey gitmez.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft hiçbir yere bir şey göndermez: Başlattığınız ajan, sizin hesabınızla kendi hizmetine bağlanır. SolidCraft hiçbir anahtarı veya parolayı görmez ve kaydetmez.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD hiçbir yere bir şey göndermez: Başlattığınız ajan, sizin hesabınızla kendi hizmetine bağlanır. OneCAD hiçbir anahtarı veya parolayı görmez ve kaydetmez.",
|
||||
"ui.agent_chat_refresh": "Yenile",
|
||||
"ui.agent_chat_refused": "Ajan için izin verilmiyor: {reason}",
|
||||
"ui.agent_chat_right": "Sağ",
|
||||
@@ -826,7 +826,7 @@
|
||||
"ui.ai_add_installed": "Yüklü ajan",
|
||||
"ui.ai_add_installed_note": "Yüklü bir ajan için ayrı bir giriş (örneğin farklı argümanlarla). Yüklü ajanlar kendiliğinden görünür.",
|
||||
"ui.ai_added": "eklendi",
|
||||
"ui.ai_agent_mode_tip": "Ajanın kendi modu: Kendi araçları (kabuk, dosya değişiklikleri) bu modu izler. SolidCraft'ın araçları Ayarlar > AI altındaki izinleri izler.",
|
||||
"ui.ai_agent_mode_tip": "Ajanın kendi modu: Kendi araçları (kabuk, dosya değişiklikleri) bu modu izler. OneCAD'ın araçları Ayarlar > AI altındaki izinleri izler.",
|
||||
"ui.ai_agents": "Ajanlar",
|
||||
"ui.ai_always_allow": "Her zaman izin ver",
|
||||
"ui.ai_always_allow_tip": "{tool} için bu ajana bundan sonra sormadan izin ver",
|
||||
@@ -834,7 +834,7 @@
|
||||
"ui.ai_always_allowed_none": "Henüz her zaman izin verilen bir şey yok. Sohbette verdiğiniz “Her zaman izin ver” yanıtları burada görünür.",
|
||||
"ui.ai_auto_allowed": "{title} için otomatik olarak izin verildi ({why})",
|
||||
"ui.ai_auto_mode": "İzin ayarınız",
|
||||
"ui.ai_auto_ours": "SolidCraft kendi araçlarını kendisi denetler",
|
||||
"ui.ai_auto_ours": "OneCAD kendi araçlarını kendisi denetler",
|
||||
"ui.ai_auto_remembered": "“Her zaman izin ver” seçeneğini seçtiniz",
|
||||
"ui.ai_bad_url": "http:// veya https:// ile başlayan bir URL girin",
|
||||
"ui.ai_base_url": "Temel URL",
|
||||
@@ -848,7 +848,7 @@
|
||||
"ui.ai_key": "API anahtarı",
|
||||
"ui.ai_key_hint": "isteğe bağlı; sistem anahtar zincirinde saklanır",
|
||||
"ui.ai_key_needs_https": "Anahtar yalnızca HTTPS üzerinden veya bu bilgisayara gönderilir.",
|
||||
"ui.ai_key_note": "SolidCraft ayarlarında değil, sistem anahtar zincirinde saklanır. Yerel bir sunucu için boş bırakın.",
|
||||
"ui.ai_key_note": "OneCAD ayarlarında değil, sistem anahtar zincirinde saklanır. Yerel bir sunucu için boş bırakın.",
|
||||
"ui.ai_keychain_error": "Sistem anahtar zinciri reddetti: {error}",
|
||||
"ui.ai_mode_all": "Her şeye izin ver",
|
||||
"ui.ai_mode_all_note": "Hiçbir işlem için önceden onay istenmez. Çökme raporlarına yine erişilemez ve klasör sınırları geçerliliğini korur.",
|
||||
@@ -859,7 +859,7 @@
|
||||
"ui.ai_mode_edits_note": "Modelleme komutları onay istemeden çalışır; kaydetme, açma, dışa aktarma ve diğer dosya ya da yıkıcı işlemler için yine onay istenir.",
|
||||
"ui.ai_mode_global": "Yukarıda ayarlandığı gibi",
|
||||
"ui.ai_model": "Model",
|
||||
"ui.ai_modes_scope": "Bu ayarlar SolidCraft araçları için geçerlidir. Bir ajanın kendi araçları (kabuğu, dosya değişiklikleri), ajanın modları varsa sohbet panelinin başlığında seçtiğiniz kendi modunu izler.",
|
||||
"ui.ai_modes_scope": "Bu ayarlar OneCAD araçları için geçerlidir. Bir ajanın kendi araçları (kabuğu, dosya değişiklikleri), ajanın modları varsa sohbet panelinin başlığında seçtiğiniz kendi modunu izler.",
|
||||
"ui.ai_name": "Ad",
|
||||
"ui.ai_need_command": "Ajanı başlatan komutu girin",
|
||||
"ui.ai_need_model": "Model adını girin",
|
||||
@@ -1372,30 +1372,30 @@
|
||||
"ui.zoom": "Yakınlaştır",
|
||||
"ui.zoom_to_cursor_tip": "Tekerlek, imlecin altındaki noktaya doğru yakınlaştırır (kapalı: görünümün merkezine doğru)",
|
||||
"ui.zoom_to_the_cursor": "İmlece doğru yakınlaştır",
|
||||
"update.available": "SolidCraft {version} kullanılabilir",
|
||||
"update.available": "OneCAD {version} kullanılabilir",
|
||||
"update.button": "Güncelle",
|
||||
"update.button_tip": "SolidCraft {version} kullanılabilir",
|
||||
"update.button_tip": "OneCAD {version} kullanılabilir",
|
||||
"update.check_automatically": "Güncellemeleri otomatik denetle",
|
||||
"update.check_now": "Şimdi denetle",
|
||||
"update.checking": "Denetleniyor…",
|
||||
"update.copy": "Kopyala",
|
||||
"update.dmg_opened": "Disk görüntüsü açıldı ve doğrulandı: SolidCraft'ı “Uygulamalar” klasörüne sürükleyin, eski sürümü değiştirin ve SolidCraft'ı yeniden başlatın.",
|
||||
"update.dmg_opened": "Disk görüntüsü açıldı ve doğrulandı: OneCAD'ı “Uygulamalar” klasörüne sürükleyin, eski sürümü değiştirin ve OneCAD'ı yeniden başlatın.",
|
||||
"update.download": "İndir",
|
||||
"update.download_open": "İndir ve aç",
|
||||
"update.downloading": "İndiriliyor ve doğrulanıyor… {done} / {total} MB",
|
||||
"update.failed": "Denetim başarısız: {error}",
|
||||
"update.file": "“İndir” düğmesi {file} dosyasını indirir.",
|
||||
"update.install_failed": "Güncelleme yüklenmedi: {error}. Hiçbir şey değiştirilmedi; paketi “İndir” ile indirebilirsiniz.",
|
||||
"update.install_msi": "Yükle (SolidCraft'ı kapatır)",
|
||||
"update.installed": "SolidCraft {version} yüklendi. Kullanmak için yeniden başlatın.",
|
||||
"update.installer_started": "Yükleme programı, SolidCraft kapatıldığında başlar.",
|
||||
"update.install_msi": "Yükle (OneCAD'ı kapatır)",
|
||||
"update.installed": "OneCAD {version} yüklendi. Kullanmak için yeniden başlatın.",
|
||||
"update.installer_started": "Yükleme programı, OneCAD kapatıldığında başlar.",
|
||||
"update.later": "Daha sonra hatırlat",
|
||||
"update.no_notes": "Bu sürüm için not bulunmuyor.",
|
||||
"update.package_hint": "Bu kurulum bir {kind} kaynağından geliyor. Yeni paketi indirip şu komutla yükleyin:",
|
||||
"update.prefs_dev": "Bu bir geliştirme derlemesi ({version}): güncelleme aramaz.",
|
||||
"update.prefs_flatpak": "Bu kurulum ({version}) Flatpak tarafından güncellenir: {command} komutunu çalıştırın veya yazılım yöneticisini kullanın. SolidCraft kendisi denetlemez.",
|
||||
"update.prefs_flatpak": "Bu kurulum ({version}) Flatpak tarafından güncellenir: {command} komutunu çalıştırın veya yazılım yöneticisini kullanın. OneCAD kendisi denetlemez.",
|
||||
"update.prefs_label": "Güncellemeler",
|
||||
"update.prefs_note": "Bu, SolidCraft {version} ({kind}). Denetim, GitHub'a en yeni sürümü sorar ve başka hiçbir şey göndermez.",
|
||||
"update.prefs_note": "Bu, OneCAD {version} ({kind}). Denetim, GitHub'a en yeni sürümü sorar ve başka hiçbir şey göndermez.",
|
||||
"update.release_page": "Sürüm sayfasını aç",
|
||||
"update.restart": "Şimdi yeniden başlat",
|
||||
"update.restart_later": "Daha sonra",
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"update.skip": "Bu sürümü atla",
|
||||
"update.system_hint": "Bu kurulum bir {kind}. Şu komutla güncelleyin:",
|
||||
"update.title": "Güncelleme mevcut",
|
||||
"update.up_to_date": "SolidCraft güncel.",
|
||||
"update.up_to_date": "OneCAD güncel.",
|
||||
"update.update_now": "Şimdi güncelle",
|
||||
"update.you_have": "Yüklü sürüm: {version}."
|
||||
}
|
||||
+25
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Натисніть Submit new issue на GitHub, щоб надіслати звіт.",
|
||||
"A new configuration, a copy of the active one": "Нова конфігурація, копія активної",
|
||||
"AI": "ШІ",
|
||||
"About SolidCraft": "Про SolidCraft",
|
||||
"About OneCAD": "Про OneCAD",
|
||||
"Acceleration": "Прискорення",
|
||||
"Active": "Активна",
|
||||
"Active rule": "Активне правило",
|
||||
@@ -248,7 +248,7 @@
|
||||
"Sketch plane has no body-face attachment.": "Площина ескізу не має прив'язки до грані тіла.",
|
||||
"Sketch points": "Точки ескізу",
|
||||
"Slot width": "Ширина паза",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft закрився до того, як ці проєкти було збережено. Можна відкрити їхнє останнє автозбереження.",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD закрився до того, як ці проєкти було збережено. Можна відкрити їхнє останнє автозбереження.",
|
||||
"Speed": "Швидкість",
|
||||
"Stand it up": "Вирівняти",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Вирівняти: поворот на чверть оберту навколо X як одна операція переміщення, яку можна скасувати.",
|
||||
@@ -672,18 +672,18 @@
|
||||
"home.report_issue": "Повідомити про проблему",
|
||||
"home.samples": "Зразки",
|
||||
"home.whats_new": "Що нового",
|
||||
"install.button_tip": "Встановити SolidCraft як застосунок: він відкривається в окремому вікні й працює без мережі",
|
||||
"install.button_tip": "Встановити OneCAD як застосунок: він відкривається в окремому вікні й працює без мережі",
|
||||
"install.dock_1": "Виберіть «Файл» › «Додати в Dock».",
|
||||
"install.dock_2": "Натисніть «Додати».",
|
||||
"install.dock_after": "Після цього SolidCraft відкривається з Dock в окремому вікні й працює без мережі.",
|
||||
"install.dock_after": "Після цього OneCAD відкривається з Dock в окремому вікні й працює без мережі.",
|
||||
"install.done": "Готово",
|
||||
"install.home_screen_1": "Торкніться «Поширити» (у меню …, якщо цієї кнопки немає на панелі).",
|
||||
"install.home_screen_2": "Виберіть «На початковий екран».",
|
||||
"install.home_screen_3": "Торкніться «Додати».",
|
||||
"install.home_screen_after": "Після цього SolidCraft відкривається значком на початковому екрані, без панелей Safari, і працює без мережі.",
|
||||
"install.home_screen_after": "Після цього OneCAD відкривається значком на початковому екрані, без панелей Safari, і працює без мережі.",
|
||||
"install.menu": "Встановити застосунок…",
|
||||
"install.steps_intro": "Safari встановлює вебзастосунки через власні меню:",
|
||||
"install.title": "Встановлення SolidCraft",
|
||||
"install.title": "Встановлення OneCAD",
|
||||
"joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці.",
|
||||
"joint.one_body": "Обидва вибори на {body}. Виберіть другий на іншій деталі.",
|
||||
"joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.",
|
||||
@@ -767,10 +767,10 @@
|
||||
"settings.frame_rate_note": "Показує кадри за секунду в правому нижньому куті виду. Поки це ввімкнено, вид перемальовується безперервно.",
|
||||
"settings.measure_axis_distances": "Відстані X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Показувати розміри вздовж світових осей разом із загальною відстанню, поки відкрите вікно вимірювання.",
|
||||
"settings.msaa_applies_on_restart": "Згладжування застосується під час наступного запуску SolidCraft",
|
||||
"settings.msaa_applies_on_restart": "Згладжування застосується під час наступного запуску OneCAD",
|
||||
"settings.msaa_reset": "{setting}: {requested}× не підтримується цим графічним пристроєм; тепер це {reset}× (підтримується: {supported}).",
|
||||
"settings.msaa_unsupported": "Не підтримується цим графічним пристроєм",
|
||||
"settings.reset_intro": "Ці налаштування не працюють на цьому комп'ютері й не давали б SolidCraft запуститися, тому їх змінено:",
|
||||
"settings.reset_intro": "Ці налаштування не працюють на цьому комп'ютері й не давали б OneCAD запуститися, тому їх змінено:",
|
||||
"settings.reset_title": "Налаштування було змінено",
|
||||
"settings.supersampling_note": "Малює вид у вищій роздільності й потім зменшує його: плавніші ребра, лінії та сітка на екранах до 4K. Це коштує часу GPU: приблизно 2,25× пікселів за 1,5× і 4× за 2×.",
|
||||
"sketch.degrees_of_freedom": "{count} ступенів вільності",
|
||||
@@ -817,8 +817,8 @@
|
||||
"ui.agent_chat_attach_view": "Прикріпити вид",
|
||||
"ui.agent_chat_attach_view_tip": "Надіслати знімок поточного виду разом із повідомленням",
|
||||
"ui.agent_chat_auth_agent": "Увійти через {name}",
|
||||
"ui.agent_chat_auth_env": "Задайте {vars} у своєму середовищі та перезапустіть SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "Агент просить вас увійти. Ви входите через сам агент; SolidCraft ніколи не бачить вашого пароля чи ключів.",
|
||||
"ui.agent_chat_auth_env": "Задайте {vars} у своєму середовищі та перезапустіть OneCAD.",
|
||||
"ui.agent_chat_auth_needed": "Агент просить вас увійти. Ви входите через сам агент; OneCAD ніколи не бачить вашого пароля чи ключів.",
|
||||
"ui.agent_chat_auth_none": "Агент тут не пропонує способу входу: увійдіть так, як описано в його власній документації.",
|
||||
"ui.agent_chat_auth_retry": "Я ввійшов: під'єднатися знову",
|
||||
"ui.agent_chat_auth_terminal": "Виконайте це в терміналі, а потім знову натисніть «Під'єднатися»:",
|
||||
@@ -840,7 +840,7 @@
|
||||
"ui.agent_chat_edit": "Редагувати",
|
||||
"ui.agent_chat_exited": "Агент зупинився.",
|
||||
"ui.agent_chat_failed_start": "Не вдалося запустити агента: {error}",
|
||||
"ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти SolidCraft: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.",
|
||||
"ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти OneCAD: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.",
|
||||
"ui.agent_chat_key_in_env": "ключ задано",
|
||||
"ui.agent_chat_left": "Ліворуч",
|
||||
"ui.agent_chat_limit": "Агент досяг межі {limit} змін на одне повідомлення. Надішліть інше повідомлення, щоб дозволити йому продовжити.",
|
||||
@@ -861,7 +861,7 @@
|
||||
"ui.agent_chat_placeholder": "Попросіть агента змінити проєкт…",
|
||||
"ui.agent_chat_placeholder_busy": "Поставте повідомлення в чергу, доки агент завершить (Ctrl+Enter: надіслати зараз)",
|
||||
"ui.agent_chat_privacy": "Нічого нікуди не надсилається, крім агента, який ви запускаєте, під вашим власним обліковим записом.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft нічого нікуди не надсилає: агент, який ви запускаєте, спілкується з власним сервісом під вашим обліковим записом. SolidCraft ніколи не бачить і не зберігає ключ чи пароль.",
|
||||
"ui.agent_chat_privacy_note": "OneCAD нічого нікуди не надсилає: агент, який ви запускаєте, спілкується з власним сервісом під вашим обліковим записом. OneCAD ніколи не бачить і не зберігає ключ чи пароль.",
|
||||
"ui.agent_chat_queue": "У чергу",
|
||||
"ui.agent_chat_queue_full": "Забагато повідомлень в очікуванні: приберіть одне або зачекайте на агента.",
|
||||
"ui.agent_chat_queued": "У черзі",
|
||||
@@ -900,7 +900,7 @@
|
||||
"ui.ai_add_installed": "Встановлений агент",
|
||||
"ui.ai_add_installed_note": "Ваш власний запис для встановленого агента, наприклад з іншими аргументами. Встановлені агенти перелічуються окремо.",
|
||||
"ui.ai_added": "додано",
|
||||
"ui.ai_agent_mode_tip": "Власний режим агента: його власні інструменти (оболонка, редагування файлів) підкоряються йому. Інструменти SolidCraft підкоряються дозволам у Налаштування > ШІ.",
|
||||
"ui.ai_agent_mode_tip": "Власний режим агента: його власні інструменти (оболонка, редагування файлів) підкоряються йому. Інструменти OneCAD підкоряються дозволам у Налаштування > ШІ.",
|
||||
"ui.ai_agents": "Агенти",
|
||||
"ui.ai_always_allow": "Завжди дозволяти",
|
||||
"ui.ai_always_allow_tip": "Дозволяти {tool} для цього агента відтепер без запиту",
|
||||
@@ -908,7 +908,7 @@
|
||||
"ui.ai_always_allowed_none": "Поки нічого не дозволено завжди. Відповіді «Завжди дозволяти», які ви даєте в чаті, перелічуються тут.",
|
||||
"ui.ai_auto_allowed": "Автоматично дозволено: {title} ({why})",
|
||||
"ui.ai_auto_mode": "ваше налаштування дозволів",
|
||||
"ui.ai_auto_ours": "SolidCraft сам перевіряє власні інструменти",
|
||||
"ui.ai_auto_ours": "OneCAD сам перевіряє власні інструменти",
|
||||
"ui.ai_auto_remembered": "ви вибрали «Завжди дозволяти»",
|
||||
"ui.ai_bad_url": "Введіть URL-адресу, що починається з http:// або https://",
|
||||
"ui.ai_base_url": "Базова URL-адреса",
|
||||
@@ -922,7 +922,7 @@
|
||||
"ui.ai_key": "Ключ API",
|
||||
"ui.ai_key_hint": "необов'язково; зберігається в системному сховищі ключів",
|
||||
"ui.ai_key_needs_https": "Ключ надсилається лише через HTTPS або на цей комп'ютер.",
|
||||
"ui.ai_key_note": "Зберігається в системному сховищі ключів, ніколи в налаштуваннях SolidCraft. Залиште порожнім для локального сервера.",
|
||||
"ui.ai_key_note": "Зберігається в системному сховищі ключів, ніколи в налаштуваннях OneCAD. Залиште порожнім для локального сервера.",
|
||||
"ui.ai_keychain_error": "Системне сховище ключів відмовило: {error}",
|
||||
"ui.ai_mode_all": "Дозволяти все",
|
||||
"ui.ai_mode_all_note": "Ніщо не запитує заздалегідь. Звіти про збої залишаються поза межами, а обмеження тек залишаються чинними.",
|
||||
@@ -933,7 +933,7 @@
|
||||
"ui.ai_mode_edits_note": "Команди моделювання виконуються без запиту; збереження, відкриття, експорт та інші файлові чи руйнівні дії все ще запитують.",
|
||||
"ui.ai_mode_global": "Як задано вище",
|
||||
"ui.ai_model": "Модель",
|
||||
"ui.ai_modes_scope": "Ці налаштування стосуються інструментів SolidCraft. Власні інструменти агента (його оболонка, його редагування файлів) підкоряються власному режиму агента, який ви вибираєте в заголовку панелі чату, коли агент має режими.",
|
||||
"ui.ai_modes_scope": "Ці налаштування стосуються інструментів OneCAD. Власні інструменти агента (його оболонка, його редагування файлів) підкоряються власному режиму агента, який ви вибираєте в заголовку панелі чату, коли агент має режими.",
|
||||
"ui.ai_name": "Назва",
|
||||
"ui.ai_need_command": "Введіть команду, що запускає агента",
|
||||
"ui.ai_need_model": "Введіть назву моделі",
|
||||
@@ -1448,31 +1448,31 @@
|
||||
"ui.zoom": "Масштабувати",
|
||||
"ui.zoom_to_cursor_tip": "Колесо масштабує до точки під курсором (вимкнено: до центру виду)",
|
||||
"ui.zoom_to_the_cursor": "Масштабувати до курсора",
|
||||
"update.available": "Доступна версія SolidCraft {version}",
|
||||
"update.available": "Доступна версія OneCAD {version}",
|
||||
"update.button": "Оновити",
|
||||
"update.button_tip": "Доступна версія SolidCraft {version}",
|
||||
"update.button_tip": "Доступна версія OneCAD {version}",
|
||||
"update.button_version": "Оновити до {version}",
|
||||
"update.check_automatically": "Автоматично перевіряти оновлення",
|
||||
"update.check_now": "Перевірити зараз",
|
||||
"update.checking": "Перевірка…",
|
||||
"update.copy": "Копіювати",
|
||||
"update.dmg_opened": "Образ диска відкрито й перевірено: перетягніть SolidCraft до Applications, замінивши стару копію, а потім перезапустіть SolidCraft.",
|
||||
"update.dmg_opened": "Образ диска відкрито й перевірено: перетягніть OneCAD до Applications, замінивши стару копію, а потім перезапустіть OneCAD.",
|
||||
"update.download": "Завантажити",
|
||||
"update.download_open": "Завантажити й відкрити",
|
||||
"update.downloading": "Завантаження та перевірка… {done} з {total} МБ",
|
||||
"update.failed": "Не вдалося перевірити: {error}",
|
||||
"update.file": "Завантаження отримає {file}.",
|
||||
"update.install_failed": "Оновлення не встановлено: {error}. Нічого не змінено; натомість «Завантажити» отримає пакет.",
|
||||
"update.install_msi": "Установити (закриє SolidCraft)",
|
||||
"update.installed": "SolidCraft {version} установлено. Перезапустіть, щоб скористатися.",
|
||||
"update.installer_started": "Інсталятор запуститься, коли SolidCraft закриється.",
|
||||
"update.install_msi": "Установити (закриє OneCAD)",
|
||||
"update.installed": "OneCAD {version} установлено. Перезапустіть, щоб скористатися.",
|
||||
"update.installer_started": "Інсталятор запуститься, коли OneCAD закриється.",
|
||||
"update.later": "Нагадати пізніше",
|
||||
"update.no_notes": "Цей випуск не має приміток.",
|
||||
"update.package_hint": "Ця копія походить з {kind}. Завантажте новий пакет, а потім установіть його за допомогою:",
|
||||
"update.prefs_dev": "Це збірка для розробки ({version}): вона не перевіряє оновлення.",
|
||||
"update.prefs_flatpak": "Flatpak оновлює цю копію ({version}): виконайте {command} або скористайтеся центром програм. SolidCraft не перевіряє.",
|
||||
"update.prefs_flatpak": "Flatpak оновлює цю копію ({version}): виконайте {command} або скористайтеся центром програм. OneCAD не перевіряє.",
|
||||
"update.prefs_label": "Оновлення",
|
||||
"update.prefs_note": "Це SolidCraft {version} ({kind}). Перевірка запитує в GitHub останній випуск і більше нічого не надсилає.",
|
||||
"update.prefs_note": "Це OneCAD {version} ({kind}). Перевірка запитує в GitHub останній випуск і більше нічого не надсилає.",
|
||||
"update.release_page": "Відкрити сторінку випуску",
|
||||
"update.restart": "Перезапустити зараз",
|
||||
"update.restart_later": "Пізніше",
|
||||
@@ -1480,7 +1480,7 @@
|
||||
"update.skip": "Пропустити цю версію",
|
||||
"update.system_hint": "Ця копія — {kind}. Оновіть її за допомогою:",
|
||||
"update.title": "Доступне оновлення",
|
||||
"update.up_to_date": "SolidCraft оновлено.",
|
||||
"update.up_to_date": "OneCAD оновлено.",
|
||||
"update.update_now": "Оновити зараз",
|
||||
"update.you_have": "У вас версія {version}."
|
||||
}
|
||||
+28
-25
@@ -6,7 +6,7 @@
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. 在 GitHub 上点击 Submit new issue 发送报告。",
|
||||
"A new configuration, a copy of the active one": "新建配置,为当前配置的副本",
|
||||
"AI": "AI",
|
||||
"About SolidCraft": "关于 SolidCraft",
|
||||
"About OneCAD": "关于 OneCAD",
|
||||
"Acceleration": "加速度",
|
||||
"Active": "活动",
|
||||
"Active rule": "活动规则",
|
||||
@@ -254,7 +254,7 @@
|
||||
"Sketch plane has no body-face attachment.": "草图平面没有实体面附着。",
|
||||
"Sketch points": "草图点",
|
||||
"Slot width": "槽宽",
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft 在这些设计保存前已关闭。可以打开它们的上次自动保存。",
|
||||
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD 在这些设计保存前已关闭。可以打开它们的上次自动保存。",
|
||||
"Speed": "速度",
|
||||
"Stand it up": "将其立起",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "将其立起:绕 X 轴旋转四分之一周,作为一个可撤销的移动特征。",
|
||||
@@ -697,18 +697,18 @@
|
||||
"home.report_issue": "报告问题",
|
||||
"home.samples": "示例",
|
||||
"home.whats_new": "新功能",
|
||||
"install.button_tip": "将 SolidCraft 安装为应用:在独立窗口中打开,离线也能使用",
|
||||
"install.button_tip": "将 OneCAD 安装为应用:在独立窗口中打开,离线也能使用",
|
||||
"install.dock_1": "选择“文件”›“添加到程序坞”。",
|
||||
"install.dock_2": "点按“添加”。",
|
||||
"install.dock_after": "之后 SolidCraft 从程序坞在独立窗口中打开,离线也能使用。",
|
||||
"install.dock_after": "之后 OneCAD 从程序坞在独立窗口中打开,离线也能使用。",
|
||||
"install.done": "完成",
|
||||
"install.home_screen_1": "轻点“共享”(若不在工具栏上,则在 … 菜单中)。",
|
||||
"install.home_screen_2": "选择“添加到主屏幕”。",
|
||||
"install.home_screen_3": "轻点“添加”。",
|
||||
"install.home_screen_after": "之后 SolidCraft 从主屏幕上的图标打开,不显示 Safari 的工具栏,离线也能使用。",
|
||||
"install.home_screen_after": "之后 OneCAD 从主屏幕上的图标打开,不显示 Safari 的工具栏,离线也能使用。",
|
||||
"install.menu": "安装应用…",
|
||||
"install.steps_intro": "Safari 通过自身的菜单安装网页应用:",
|
||||
"install.title": "安装 SolidCraft",
|
||||
"install.title": "安装 OneCAD",
|
||||
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。",
|
||||
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
|
||||
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
|
||||
@@ -792,10 +792,10 @@
|
||||
"settings.frame_rate_note": "在视图右下角显示每秒帧数。开启时视图会持续重绘。",
|
||||
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
|
||||
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
|
||||
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
|
||||
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 OneCAD 时生效",
|
||||
"settings.msaa_reset": "{setting}: {requested}× 不受此图形设备支持;已重置为 {reset}× (支持: {supported})。",
|
||||
"settings.msaa_unsupported": "此图形设备不支持",
|
||||
"settings.reset_intro": "这些设置在此计算机上无法正常工作,会导致 SolidCraft 无法启动,因此已更改:",
|
||||
"settings.reset_intro": "这些设置在此计算机上无法正常工作,会导致 OneCAD 无法启动,因此已更改:",
|
||||
"settings.reset_title": "设置已更改",
|
||||
"settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。",
|
||||
"sketch.degrees_of_freedom": "{count} 个自由度",
|
||||
@@ -848,8 +848,8 @@
|
||||
"ui.agent_chat_attach_view": "附加视图",
|
||||
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
|
||||
"ui.agent_chat_auth_agent": "使用 {name} 登录",
|
||||
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
|
||||
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
|
||||
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 OneCAD。",
|
||||
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;OneCAD 从不接触你的密码或密钥。",
|
||||
"ui.agent_chat_auth_none": "该智能体未在此提供登录方式:请按照它自己的文档登录。",
|
||||
"ui.agent_chat_auth_retry": "我已登录:重新连接",
|
||||
"ui.agent_chat_auth_terminal": "在终端中运行以下命令,然后再次点击连接:",
|
||||
@@ -866,12 +866,13 @@
|
||||
"ui.agent_chat_deny": "拒绝",
|
||||
"ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。",
|
||||
"ui.agent_chat_detecting": "正在查找智能体…",
|
||||
"ui.agent_chat_dock": "停靠到侧面板",
|
||||
"ui.agent_chat_dock_left": "停靠在左侧",
|
||||
"ui.agent_chat_dock_right": "停靠在右侧",
|
||||
"ui.agent_chat_edit": "编辑",
|
||||
"ui.agent_chat_exited": "智能体已停止。",
|
||||
"ui.agent_chat_failed_start": "智能体无法启动:{error}",
|
||||
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
|
||||
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 OneCAD 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
|
||||
"ui.agent_chat_key_in_env": "已设置密钥",
|
||||
"ui.agent_chat_left": "左侧",
|
||||
"ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。",
|
||||
@@ -887,12 +888,14 @@
|
||||
"ui.agent_chat_none_found_short": "未找到智能体",
|
||||
"ui.agent_chat_not_installed": "未安装",
|
||||
"ui.agent_chat_not_signed_in": "未登录",
|
||||
"ui.agent_chat_own_window": "独立窗口",
|
||||
"ui.agent_chat_permission": "智能体请求运行:{title}",
|
||||
"ui.agent_chat_pick": "智能体",
|
||||
"ui.agent_chat_placeholder": "让智能体修改设计…",
|
||||
"ui.agent_chat_placeholder_busy": "输入消息,待智能体完成后发送(Ctrl+Enter:立即发送)",
|
||||
"ui.agent_chat_pop_out": "在独立窗口中打开(可移到另一个屏幕)",
|
||||
"ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。",
|
||||
"ui.agent_chat_privacy_note": "OneCAD 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。OneCAD 从不查看或保存任何密钥或密码。",
|
||||
"ui.agent_chat_queue": "加入队列",
|
||||
"ui.agent_chat_queue_full": "等待中的消息太多:请删除一条,或等待智能体完成。",
|
||||
"ui.agent_chat_queued": "已排队",
|
||||
@@ -931,7 +934,7 @@
|
||||
"ui.ai_add_installed": "已安装的智能体",
|
||||
"ui.ai_add_installed_note": "为已安装的智能体添加一个自己的条目,例如使用其他参数。已安装的智能体会自动列出。",
|
||||
"ui.ai_added": "已添加",
|
||||
"ui.ai_agent_mode_tip": "智能体自己的模式:它自己的工具(shell、文件编辑)遵循此模式。SolidCraft 的工具遵循“首选项 > AI”中的权限。",
|
||||
"ui.ai_agent_mode_tip": "智能体自己的模式:它自己的工具(shell、文件编辑)遵循此模式。OneCAD 的工具遵循“首选项 > AI”中的权限。",
|
||||
"ui.ai_agents": "智能体",
|
||||
"ui.ai_always_allow": "始终允许",
|
||||
"ui.ai_always_allow_tip": "从现在起允许该智能体使用 {tool},不再询问",
|
||||
@@ -939,7 +942,7 @@
|
||||
"ui.ai_always_allowed_none": "尚无始终允许的项目。你在聊天中选择“始终允许”的回答会列在这里。",
|
||||
"ui.ai_auto_allowed": "已自动允许:{title}({why})",
|
||||
"ui.ai_auto_mode": "你的权限设置",
|
||||
"ui.ai_auto_ours": "SolidCraft 会自行检查它自己的工具",
|
||||
"ui.ai_auto_ours": "OneCAD 会自行检查它自己的工具",
|
||||
"ui.ai_auto_remembered": "你选择了始终允许",
|
||||
"ui.ai_bad_url": "请输入以 http:// 或 https:// 开头的 URL",
|
||||
"ui.ai_base_url": "基础 URL",
|
||||
@@ -953,7 +956,7 @@
|
||||
"ui.ai_key": "API 密钥",
|
||||
"ui.ai_key_hint": "可选;保存在系统钥匙串中",
|
||||
"ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。",
|
||||
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。",
|
||||
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 OneCAD 的设置里。本地服务器可留空。",
|
||||
"ui.ai_keychain_error": "系统钥匙串拒绝:{error}",
|
||||
"ui.ai_mode_all": "全部允许",
|
||||
"ui.ai_mode_all_note": "不再询问。崩溃报告仍不可访问,文件夹限制仍然有效。",
|
||||
@@ -964,7 +967,7 @@
|
||||
"ui.ai_mode_edits_note": "建模命令无需询问即可运行;保存、打开、导出等文件操作或破坏性操作仍会询问。",
|
||||
"ui.ai_mode_global": "按上方设置",
|
||||
"ui.ai_model": "模型",
|
||||
"ui.ai_modes_scope": "这些设置适用于 SolidCraft 的工具。智能体自己的工具(它的 shell、文件编辑)遵循智能体自己的模式;如果智能体提供模式,可在聊天面板标题栏中选择。",
|
||||
"ui.ai_modes_scope": "这些设置适用于 OneCAD 的工具。智能体自己的工具(它的 shell、文件编辑)遵循智能体自己的模式;如果智能体提供模式,可在聊天面板标题栏中选择。",
|
||||
"ui.ai_name": "名称",
|
||||
"ui.ai_need_command": "请输入启动智能体的命令",
|
||||
"ui.ai_need_model": "请输入模型名称",
|
||||
@@ -1494,31 +1497,31 @@
|
||||
"ui.zoom": "缩放",
|
||||
"ui.zoom_to_cursor_tip": "滚轮向光标所在点缩放(关闭:向视图中心缩放)",
|
||||
"ui.zoom_to_the_cursor": "缩放到光标",
|
||||
"update.available": "SolidCraft {version} 已发布",
|
||||
"update.available": "OneCAD {version} 已发布",
|
||||
"update.button": "更新",
|
||||
"update.button_tip": "SolidCraft {version} 已发布",
|
||||
"update.button_tip": "OneCAD {version} 已发布",
|
||||
"update.button_version": "更新到 {version}",
|
||||
"update.check_automatically": "自动检查更新",
|
||||
"update.check_now": "立即检查",
|
||||
"update.checking": "正在检查…",
|
||||
"update.copy": "复制",
|
||||
"update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。",
|
||||
"update.dmg_opened": "磁盘映像已校验并打开:将 OneCAD 拖到“应用程序”替换旧版本,然后重启 OneCAD。",
|
||||
"update.download": "下载",
|
||||
"update.download_open": "下载并打开",
|
||||
"update.downloading": "正在下载并校验… {done} / {total} MB",
|
||||
"update.failed": "无法检查: {error}",
|
||||
"update.file": "将下载 {file}。",
|
||||
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。",
|
||||
"update.install_msi": "安装(将关闭 SolidCraft)",
|
||||
"update.installed": "SolidCraft {version} 已安装。重新启动后生效。",
|
||||
"update.installer_started": "SolidCraft 关闭后将启动安装程序。",
|
||||
"update.install_msi": "安装(将关闭 OneCAD)",
|
||||
"update.installed": "OneCAD {version} 已安装。重新启动后生效。",
|
||||
"update.installer_started": "OneCAD 关闭后将启动安装程序。",
|
||||
"update.later": "稍后提醒",
|
||||
"update.no_notes": "此版本没有发布说明。",
|
||||
"update.package_hint": "此副本来自 {kind}。下载新软件包后用以下命令安装:",
|
||||
"update.prefs_dev": "这是开发版本({version}),不检查更新。",
|
||||
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。SolidCraft 不会自行检查。",
|
||||
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。OneCAD 不会自行检查。",
|
||||
"update.prefs_label": "更新",
|
||||
"update.prefs_note": "当前为 SolidCraft {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
|
||||
"update.prefs_note": "当前为 OneCAD {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
|
||||
"update.release_page": "打开发布页面",
|
||||
"update.restart": "立即重启",
|
||||
"update.restart_later": "稍后",
|
||||
@@ -1526,7 +1529,7 @@
|
||||
"update.skip": "跳过此版本",
|
||||
"update.system_hint": "此副本为 {kind}。用以下命令更新:",
|
||||
"update.title": "有可用更新",
|
||||
"update.up_to_date": "SolidCraft 已是最新版本。",
|
||||
"update.up_to_date": "OneCAD 已是最新版本。",
|
||||
"update.update_now": "立即更新",
|
||||
"update.you_have": "当前版本为 {version}。"
|
||||
}
|
||||
@@ -3,5 +3,7 @@
|
||||
#solidcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #4a5060; font: 14px system-ui, sans-serif; background: #e8ebf0; z-index: 10; transition: opacity 300ms ease; }
|
||||
#solidcraft_loading.done { opacity: 0; pointer-events: none; }
|
||||
#solidcraft_loading img { width: min(60vmin, 360px); height: auto; }
|
||||
/* OneBSpace (OneCAD): the upstream logo image is gone — a plain wordmark instead. */
|
||||
#solidcraft_loading .wordmark { font: 600 clamp(28px, 6vmin, 52px) system-ui, sans-serif;
|
||||
letter-spacing: 0.22em; text-indent: 0.22em; color: #4a5060; user-select: none; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
@@ -253,9 +253,20 @@ impl Document {
|
||||
return self.certified_pieces(body, indices, target.origin_feature);
|
||||
}
|
||||
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
|
||||
}).collect();
|
||||
let side = |index: usize, exact: bool| matches!(body.body.face_sources().and_then(|sources| sources.get(index)), Some(Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. })) if *producer == target.origin_feature && current_sketch == sketch && (!exact || current_curve == curve));
|
||||
let count = body.body.face_sources().map_or(0, |sources| sources.len());
|
||||
let mut indices: Vec<_> = (0..count).filter(|index| side(*index, true)).collect();
|
||||
// A side drawn in collinear pieces is one face over all of them (#1788): a certificate
|
||||
// made when the pieces were faces of their own finds it by the piece's name, which
|
||||
// the joined face answers to.
|
||||
if indices.is_empty() {
|
||||
let names = crate::naming::names(body);
|
||||
indices = (0..count)
|
||||
.filter(|index| {
|
||||
side(*index, false) && crate::history_names::matches(&names, *index, &target.name) == Some(crate::history_names::Match::Alias)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
|
||||
}
|
||||
|
||||
+29
-15
@@ -1764,8 +1764,9 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
Operation::Cut | Operation::Intersect => {
|
||||
let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect };
|
||||
let tools = if op == Operation::Cut { clear_tools_as_one(tools) } else { tools };
|
||||
let affected: std::collections::BTreeSet<String> =
|
||||
target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
|
||||
// Each target's pieces before the cut: a body joined from separate pieces stays one.
|
||||
let affected: std::collections::BTreeMap<String, usize> =
|
||||
target_idx.iter().filter_map(|i| state.bodies.get(*i)).map(|b| (b.name.clone(), b.body.lumps().map_or(1, |l| l.len()))).collect();
|
||||
let mut remove = Vec::new();
|
||||
for &ti in &target_idx {
|
||||
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.traced())) else { continue };
|
||||
@@ -1793,16 +1794,19 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
state.threads.retain(|t| live.contains(t.body.as_str()));
|
||||
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"),
|
||||
// numbered on past names already taken (a body cut in pieces before).
|
||||
// numbered on past names already taken (a body cut in pieces before). Only a cut that
|
||||
// made more pieces than the body had splits it: the pieces of a join stay together.
|
||||
// Not yet: a cut that splits one piece of such a body still splits all of them; keeping
|
||||
// the untouched ones together needs each new piece traced to the piece it came from.
|
||||
let mut taken: std::collections::BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let mut split: Vec<ModelBody> = Vec::new();
|
||||
for mb in std::mem::take(&mut state.bodies) {
|
||||
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
|
||||
let Some(&had) = affected.get(&mb.name).filter(|_| !state.sheets.iter().any(|s| s.body == mb.name)) else {
|
||||
split.push(mb);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]);
|
||||
if pieces.len() < 2 {
|
||||
if pieces.len() <= had {
|
||||
split.push(mb);
|
||||
continue;
|
||||
}
|
||||
@@ -3079,7 +3083,8 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
|
||||
};
|
||||
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
|
||||
// wire can tilt an otherwise exact planar cap through numeric cancellation.
|
||||
let bodies = kernel::extrude(&Plane::XY, std::slice::from_ref(region), start, end)?;
|
||||
// A side drawn in collinear pieces is one face (#1788).
|
||||
let bodies = kernel::extrude_whole_sides(&Plane::XY, std::slice::from_ref(region), start, end)?;
|
||||
if *plane == Plane::XY {
|
||||
return Ok(bodies.into_iter().map(|body| roles.tool(body, None)).collect());
|
||||
}
|
||||
@@ -3190,21 +3195,30 @@ fn feature_tools_with_constructor(
|
||||
};
|
||||
if let Some(tp) = &extent.taper {
|
||||
let taper = val(vals, tp, Kind::Angle)?;
|
||||
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
|
||||
return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into()));
|
||||
if extent.distance2.is_some() || extent.direction == crate::Direction::Symmetric {
|
||||
return Err(DocError::Invalid("not supported yet: taper with two-sided or symmetric extents".into()));
|
||||
}
|
||||
let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
|
||||
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
|
||||
let start_plane = ss.plane.offset(from);
|
||||
// Where the extrude starts: the From Object's level and a start offset along the
|
||||
// normal from it. The offset moves the profile that far and the sides are drafted
|
||||
// from there, so the profile stays the section at the start whatever the offset
|
||||
// (`d` is measured from the start already, To Object included).
|
||||
let base = from
|
||||
+ match &extent.start_offset {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let start_plane = ss.plane.offset(base);
|
||||
let caps = [
|
||||
Some(crate::lineage::extrude_cap_role(from)),
|
||||
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { from - len } else { from + len })),
|
||||
Some(crate::lineage::extrude_cap_role(base)),
|
||||
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { base - len } else { base + len })),
|
||||
];
|
||||
// How far a tool grows past its profile at either end, at most.
|
||||
let mut growth: f64 = 0.0;
|
||||
// A face of the targets in the profile's plane (the same for every profile).
|
||||
let plain_targets: Vec<String> = crate::plain(targets);
|
||||
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, &plain_targets);
|
||||
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, base, &plain_targets);
|
||||
let surroundings = Surroundings::of(st, *operation, &plain_targets);
|
||||
let tools: Vec<Traced> = regions
|
||||
.iter()
|
||||
@@ -3228,9 +3242,9 @@ fn feature_tools_with_constructor(
|
||||
growth = growth.max(len * taper.tan().abs());
|
||||
return Ok(roles.tool(exact, None));
|
||||
}
|
||||
let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) };
|
||||
let (lo, hi) = if sign < 0.0 { (base - len, base) } else { (base, base + len) };
|
||||
let (l2, h2) = surroundings.as_ref().map_or((lo, hi), |sur| extend_for_coplanar(sur, &ss.plane, r, lo, hi));
|
||||
let (mut a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
|
||||
let (mut a, b) = if sign < 0.0 { (base - h2, base - l2) } else { (l2 - base, h2 - base) };
|
||||
// A taper that narrows the tool away from the face widens it as it runs on into it, past
|
||||
// the profile (where the profile is the whole face, out through the body's sides, #1534):
|
||||
// then it starts on the face instead.
|
||||
|
||||
@@ -808,3 +808,43 @@ fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two posts joined into one body while apart, trimmed by a cut that splits neither, then joined
|
||||
/// to a case they stand on. The cut split the joined posts into "Posts" and "Posts (1)" without
|
||||
/// saying so (`created_bodies` was empty), and the join then took only "Posts", leaving the other
|
||||
/// post behind as a body of its own. A cut that does cut a body in two still makes two bodies,
|
||||
/// and its reply names the new one.
|
||||
#[test]
|
||||
fn cut_keeps_posts_joined_while_apart_in_one_body() {
|
||||
let mut s = Session::default();
|
||||
let post = |name: &str, x: f64| json!({"length": 10, "width": 10, "height": 30, "center": [x, 0, 25], "operation": "new", "body_name": name});
|
||||
s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 10, "center": [0, 0, 5], "operation": "new", "body_name": "Case"})).unwrap();
|
||||
s.execute("solid.box", &post("Posts", -20.0)).unwrap();
|
||||
s.execute("solid.box", &post("Post2", 20.0)).unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 40], "operation": "new", "body_name": "Trim"})).unwrap();
|
||||
let cut = s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Trim"], "operation": "cut"})).unwrap();
|
||||
// Each post loses its top 5 mm: 10 x 10 x 25 left of each.
|
||||
let posts = 2.0 * 10.0 * 10.0 * 25.0;
|
||||
assert_eq!(cut["bodies"], json!(["Case", "Posts"]), "{cut}");
|
||||
assert_eq!(cut["created_bodies"], json!([]), "{cut}");
|
||||
assert_eq!(s.model.state().body("Posts").map(|b| b.body.shell_count()), Some(2));
|
||||
assert!((volume(&s, "Posts") - posts).abs() < 1e-6 * posts, "{}", volume(&s, "Posts"));
|
||||
let join = s.execute("solid.combine", &json!({"target": "Case", "tools": ["Posts"], "operation": "join"})).unwrap();
|
||||
assert_eq!(join["bodies"], json!(["Case"]), "{join}");
|
||||
assert_eq!(join["removed_bodies"], json!(["Posts"]), "{join}");
|
||||
let want = 60.0 * 40.0 * 10.0 + posts;
|
||||
let got = volume(&s, "Case");
|
||||
println!("posts joined while apart, cut, joined: Case {got:.6} mm³, expected {want:.6}");
|
||||
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
|
||||
|
||||
// A cut through the middle of the case still leaves two bodies, and says it made the second.
|
||||
s.execute("solid.box", &json!({"length": 2, "width": 80, "height": 80, "center": [0, 0, 20], "operation": "new", "body_name": "Saw"})).unwrap();
|
||||
let saw = s.execute("solid.combine", &json!({"target": "Case", "tools": ["Saw"], "operation": "cut"})).unwrap();
|
||||
assert_eq!(saw["bodies"], json!(["Case", "Case (1)"]), "{saw}");
|
||||
assert_eq!(saw["created_bodies"], json!(["Case (1)"]), "{saw}");
|
||||
let half = (60.0 - 2.0) / 2.0 * 40.0 * 10.0 + posts / 2.0;
|
||||
for name in ["Case", "Case (1)"] {
|
||||
assert!((volume(&s, name) - half).abs() < 1e-6 * half, "{name}: {} vs {half}", volume(&s, name));
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let component = component_of(s, p, cmd)?;
|
||||
let items = keys(p, "items", cmd)?;
|
||||
let d = s.doc_mut();
|
||||
let id = d.browser_groups.iter().map(|g| g.id).max().unwrap_or(0) + 1;
|
||||
let id = super::next_free_id(cmd, d.browser_groups.iter().map(|g| g.id))?;
|
||||
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) if n.len() <= 128 => n.to_string(),
|
||||
Some(_) => return Err(bad(cmd, "`name` is too long")),
|
||||
@@ -1004,6 +1004,31 @@ mod tests {
|
||||
assert_eq!(s.visibility, before);
|
||||
}
|
||||
|
||||
/// A design file can carry any id, the largest a `u64` holds too: a new group then has no id
|
||||
/// to take. That is an error, not an overflow (a panic where overflow is checked, a duplicate
|
||||
/// id where it is not).
|
||||
#[test]
|
||||
fn a_new_group_after_the_largest_id_is_an_error_not_an_overflow() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let a = names(&s)[0].clone();
|
||||
run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}));
|
||||
s.doc_mut().browser_groups[0].id = u64::MAX;
|
||||
let result = s.execute("browser.group", &json!({"folder": "bodies", "items": [a]}));
|
||||
assert!(!matches!(result, Err(crate::EngineError::Internal(..))), "panicked inside the command: {result:?}");
|
||||
assert!(result.is_err(), "no id is left to give: {result:?}");
|
||||
assert_eq!(s.doc.browser_groups.len(), 1, "nothing was added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_free_id_is_one_past_the_largest_and_never_wraps() {
|
||||
use super::super::next_free_id;
|
||||
assert_eq!(next_free_id("t", std::iter::empty()).unwrap(), 1);
|
||||
assert_eq!(next_free_id("t", [3, 9, 4].into_iter()).unwrap(), 10);
|
||||
assert_eq!(next_free_id("t", [u64::MAX - 1].into_iter()).unwrap(), u64::MAX);
|
||||
assert!(next_free_id("t", [1, u64::MAX].into_iter()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_hold_items_and_follow_renames() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -547,10 +547,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
// The bodies this feature made, apart from the ones it changed or that were there (#374).
|
||||
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
// Bodies that were there before and now have other geometry, and bodies that are gone.
|
||||
let old: std::collections::HashMap<&str, &solidcraft_doc::kernel::Body> = before.bodies.iter().map(|b| (b.name.as_str(), &b.body)).collect();
|
||||
// The bodies this feature made, apart from the ones it changed or that were there (#374),
|
||||
// and the pieces a cut left of a body made earlier, which keep that body's feature.
|
||||
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id || !old.contains_key(b.name.as_str())).map(|b| b.name.as_str()).collect();
|
||||
let now: std::collections::HashSet<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
let changed: Vec<&str> =
|
||||
st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();
|
||||
|
||||
@@ -952,6 +952,165 @@ fn a_tapered_foot_under_a_part_joins_as_drawn() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A drafted extrude that starts above its sketch plane. The start offset moves the profile
|
||||
/// along the sketch normal and the walls are drafted from there, so the section at the start
|
||||
/// plane is the profile itself and the solid is the frustum over it, moved along the normal by
|
||||
/// the offset. Controls that build today: the same extrude with the taper and no offset, and
|
||||
/// with the offset and no taper. Two-sided and symmetric tapers are still refused, by name.
|
||||
#[test]
|
||||
fn a_drafted_extrude_starts_at_an_offset() {
|
||||
use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body};
|
||||
// A 30 x 20 profile, 12 mm long, drafted 6 deg, started 4 mm along the normal.
|
||||
let (w, d, h, off) = (30.0, 20.0, 12.0, 4.0);
|
||||
let part = |p: Value| -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", p);
|
||||
s
|
||||
};
|
||||
// The straight prism, and the frustum over the profile: every edge moves out by z*tan a
|
||||
// over the start, so A(z) = (w + 2 z t)(d + 2 z t) and
|
||||
// V = integral of A over 0..h = w*d*h + (w + d)*t*h^2 + 4/3*t^2*h^3 (exact: A is quadratic).
|
||||
let prism = w * d * h;
|
||||
let frustum = |t: f64| w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h;
|
||||
// The two caps plus four trapezoid walls, each (base + top)/2 by the slant height h/cos a.
|
||||
let area_of = |a: f64| {
|
||||
let t = a.tan();
|
||||
w * d + (w + 2.0 * h * t) * (d + 2.0 * h * t) + 2.0 * h / a.cos() * ((w + h * t) + (d + h * t))
|
||||
};
|
||||
|
||||
// Control: the taper alone, from the sketch plane.
|
||||
let want = frustum(6f64.to_radians().tan());
|
||||
let mut s = part(json!({"distance":h,"taper":"6 deg","body_name":"B"}));
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, want) < 1e-9, "taper alone: {v} mm3 for {want}");
|
||||
|
||||
// Control: the offset alone, no taper: the prism, moved along the normal.
|
||||
let mut s = part(json!({"distance":h,"start_offset":off,"body_name":"B"}));
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, prism) < 1e-9, "offset alone: {v} mm3 for {prism}");
|
||||
let st = s.model.state();
|
||||
let m = measure_body(&st.body("B").unwrap().body).unwrap();
|
||||
assert!((m.bbox.min.z - off).abs() < 1e-9 && (m.bbox.max.z - (off + h)).abs() < 1e-9, "offset alone: {:?}", m.bbox);
|
||||
|
||||
// Both: the frustum over the profile, standing on the offset plane, either way along the
|
||||
// normal and drafted either outward or inward.
|
||||
for deg in [6.0f64, -6.0] {
|
||||
for (dir, sign) in [("positive", 1.0f64), ("negative", -1.0)] {
|
||||
let (ang, t) = (deg.to_radians(), deg.to_radians().tan());
|
||||
let (v_want, a_want) = (frustum(t), area_of(ang));
|
||||
let at = format!("{deg} deg {dir}");
|
||||
let mut s = part(json!({"distance":h,"direction":dir,"start_offset":off,"taper":format!("{deg} deg"),"body_name":"B"}));
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, v_want) < 1e-9, "{at}: {v} mm3 for {v_want}");
|
||||
let st = s.model.state();
|
||||
let b = &st.body("B").unwrap().body;
|
||||
let m = measure_body(b).unwrap();
|
||||
assert_eq!(st.bodies.len(), 1, "{at}: one body");
|
||||
assert_eq!(m.shells, 1, "{at}: one lump");
|
||||
assert_eq!(m.merged.faces, 6, "{at}: four walls and two caps");
|
||||
// The area pins both caps: the profile at the offset plane, the drafted section at
|
||||
// the far end. (The volume alone cannot: it is measured on a tessellation.)
|
||||
assert!(rel(m.area, a_want) < 1e-9, "{at}: {} mm2 for {a_want}", m.area);
|
||||
// The solid lies between the offset plane and the far end, and the widening (or
|
||||
// narrowing) is all on the far side of the profile.
|
||||
let (lo, far) = if sign > 0.0 { (off, off + h) } else { (off - h, off) };
|
||||
assert!((m.bbox.min.z - lo).abs() < 1e-9 && (m.bbox.max.z - (lo + h)).abs() < 1e-9, "{at}: {:?}", m.bbox);
|
||||
let (x0, x1) = (0f64.min(-h * t), w.max(w + h * t));
|
||||
let (y0, y1) = (0f64.min(-h * t), d.max(d + h * t));
|
||||
assert!((m.bbox.min.x - x0).abs() < 1e-9 && (m.bbox.max.x - x1).abs() < 1e-9, "{at}: {:?} for x {x0}..{x1}", m.bbox);
|
||||
assert!((m.bbox.min.y - y0).abs() < 1e-9 && (m.bbox.max.y - y1).abs() < 1e-9, "{at}: {:?} for y {y0}..{y1}", m.bbox);
|
||||
// Every face names an exact surface: four planes leaning by the taper and two flat
|
||||
// caps, one of them the profile's plane at the offset. A tessellated stand-in
|
||||
// measures nearly the same volume and names no surface at all.
|
||||
let (mut walls, mut caps) = (0, 0);
|
||||
for i in 0..b.face_count() {
|
||||
match face_surface(b, i) {
|
||||
Some(FaceSurface::Plane { normal, point }) if normal.z.abs() > 1.0 - 1e-9 => {
|
||||
caps += 1;
|
||||
assert!((point.z - lo).abs() < 1e-9 || (point.z - far).abs() < 1e-9, "{at}: a cap at z = {}", point.z);
|
||||
}
|
||||
Some(FaceSurface::Plane { normal, .. }) => {
|
||||
walls += 1;
|
||||
assert!((normal.z + sign * ang.sin()).abs() < 1e-9, "{at}: a wall leaning {}, not {}", normal.z, -sign * ang.sin());
|
||||
}
|
||||
other => panic!("{at}: face {i} is {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!((walls, caps), (4, 2), "{at}");
|
||||
}
|
||||
}
|
||||
|
||||
// The extents that are still to come are refused, and say which they are.
|
||||
for p in [json!({"distance":h,"distance2":5,"taper":"6 deg"}), json!({"distance":h,"direction":"symmetric","taper":"6 deg"})] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let e = s.execute("solid.extrude", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet: taper with two-sided or symmetric extents"), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drafted pocket that starts above its sketch plane, cut into a body three booleans deep:
|
||||
/// its rim lies on a face bounded by an intersection curve, not by straight lines. The tool
|
||||
/// lies wholly in material, so the cut removes exactly the frustum over its profile.
|
||||
#[test]
|
||||
fn a_drafted_offset_cut_into_a_body_with_history() {
|
||||
use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body};
|
||||
let (w, d, h, off) = (30.0, 20.0, 12.0, 18.0);
|
||||
let (ang, t) = (6f64.to_radians(), 6f64.to_radians().tan());
|
||||
let frustum = w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h;
|
||||
let unnamed = |b: &solidcraft_kernel::Body| (0..b.face_count()).filter(|i| face_surface(b, *i).is_none()).count();
|
||||
|
||||
let mut s = Session::default();
|
||||
// A 60 x 40 x 30 block, a round boss joined through its far side and out of its top, and a
|
||||
// round pocket cut in the top: the top face is bounded by the boss's intersection curve.
|
||||
run(&mut s, "solid.box", json!({"length":60,"width":40,"height":30,"corner":[0,0,0],"body_name":"P"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius":8,"height":46,"base":[58,20,-8],"operation":"join"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius":4,"height":12,"base":[10,34,20],"operation":"cut"}));
|
||||
let before = volume(&mut s);
|
||||
{
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), 1, "the block, the boss and the pocket are one body");
|
||||
assert_eq!(unnamed(&st.body("P").unwrap().body), 0, "faces without a surface before the cut");
|
||||
}
|
||||
|
||||
// The pocket: drafted 6 deg outward, starting 18 mm up the normal and out through the top.
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[35,25]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":h,"start_offset":off,"taper":"6 deg","operation":"cut"}));
|
||||
let after = volume(&mut s);
|
||||
let removed = before - after;
|
||||
assert!((removed - frustum).abs() < 1e-6 * frustum, "{removed} mm3 removed for the frustum's {frustum}");
|
||||
|
||||
let st = s.model.state();
|
||||
let b = &st.body("P").unwrap().body;
|
||||
let m = measure_body(b).unwrap();
|
||||
assert_eq!(st.bodies.len(), 1, "one body");
|
||||
assert_eq!(m.shells, 1, "one lump: the pocket is open at the top");
|
||||
assert_eq!(unnamed(b), 0, "faces without a surface after the cut");
|
||||
// Four drafted walls, and the pocket's floor at the offset: the only leaning planes of the
|
||||
// body (the block's own faces are level or upright), and the only face in the plane the cut
|
||||
// starts on. A cut keeps the tool's sides facing into the pocket, so a wall drafted outward
|
||||
// by a leans by +sin a, the other way round from the same extrude as a new body.
|
||||
let (mut walls, mut floor) = (0, 0);
|
||||
for i in 0..b.face_count() {
|
||||
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, i) else { continue };
|
||||
if (normal.z - ang.sin()).abs() < 1e-9 {
|
||||
walls += 1;
|
||||
} else if normal.z > 1.0 - 1e-9 && (point.z - off).abs() < 1e-9 {
|
||||
floor += 1;
|
||||
} else {
|
||||
assert!(normal.z.abs() < 1e-9 || normal.z.abs() > 1.0 - 1e-9, "a plane leaning {}, neither the block's nor the pocket's", normal.z);
|
||||
}
|
||||
}
|
||||
assert_eq!((walls, floor), (4, 1), "four drafted walls, and the pocket's floor at z = {off}");
|
||||
}
|
||||
|
||||
/// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top
|
||||
/// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front
|
||||
/// face and in a knife-edge tip where it runs into the back face.
|
||||
|
||||
@@ -393,8 +393,8 @@ fn limits_param(s: &Session, p: &Value, kind: JointKind, cmd: &str) -> Result<Ve
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn next_joint_id(s: &Session) -> u64 {
|
||||
s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0) + 1
|
||||
fn next_joint_id(s: &Session, cmd: &str) -> Result<u64> {
|
||||
super::next_free_id(cmd, s.doc.assembly.joints.iter().map(|j| j.id))
|
||||
}
|
||||
|
||||
fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
|
||||
@@ -474,7 +474,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = kind_param(p, cmd)?;
|
||||
let a = origin_param(s, p.get("a"), cmd)?;
|
||||
let b = origin_param(s, p.get("b"), cmd)?;
|
||||
let id = next_joint_id(s);
|
||||
let id = next_joint_id(s, cmd)?;
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
|
||||
let offset = match p.get("offset") {
|
||||
Some(v) => value(s, v, false, cmd)?,
|
||||
@@ -514,7 +514,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let fa = s.doc.frame_in_occurrence(oa, &w).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let fb = s.doc.frame_in_occurrence(ob, &w).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let id = next_joint_id(s);
|
||||
let id = next_joint_id(s, cmd)?;
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
|
||||
let n = kind.dofs().len();
|
||||
let j = Joint {
|
||||
|
||||
@@ -287,6 +287,13 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
|
||||
}
|
||||
|
||||
/// The id for a new item of a list: one past the largest id in it. Ids come from the design file,
|
||||
/// so the largest may be the largest a `u64` holds, and one past that is an error, not an overflow
|
||||
/// (a panic where overflow is checked, an id that is already taken where it is not).
|
||||
pub(crate) fn next_free_id(cmd: &str, ids: impl Iterator<Item = u64>) -> Result<u64> {
|
||||
ids.max().unwrap_or(0).checked_add(1).ok_or_else(|| crate::params::bad(cmd, "this design has no ids left to give"))
|
||||
}
|
||||
|
||||
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
|
||||
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
|
||||
/// (`file.open` → `doc.open`). Empty when nothing is close.
|
||||
|
||||
@@ -95,10 +95,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
fn next_id(s: &Session) -> u64 {
|
||||
fn next_id(s: &Session, cmd: &str) -> Result<u64> {
|
||||
let a = &s.doc.assembly;
|
||||
let ids = a.contact_sets.iter().map(|c| c.id).chain(a.studies.iter().map(|m| m.id)).chain(a.exploded.iter().map(|e| e.id));
|
||||
ids.max().unwrap_or(0) + 1
|
||||
super::next_free_id(cmd, ids)
|
||||
}
|
||||
|
||||
fn occurrences(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
|
||||
@@ -424,7 +424,7 @@ fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc.assembly.contact_sets.len() >= MAX_ITEMS {
|
||||
return Err(bad(cmd, "too many contact sets"));
|
||||
}
|
||||
let id = next_id(s);
|
||||
let id = next_id(s, cmd)?;
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Contact Set{}", s.doc.assembly.contact_sets.len() + 1));
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
a.contact_sets.push(ContactSet { id, name: name.clone(), occurrences: occ, suppressed: false });
|
||||
@@ -532,7 +532,7 @@ fn study(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
keys.push(StudyKey { joint, index, points });
|
||||
}
|
||||
}
|
||||
let id = next_id(s);
|
||||
let id = next_id(s, cmd)?;
|
||||
let count = s.doc.assembly.studies.len();
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
let out = match existing.and_then(|i| a.studies.get_mut(i)) {
|
||||
@@ -952,7 +952,7 @@ fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc.assembly.exploded.len() >= MAX_ITEMS {
|
||||
return Err(bad(cmd, "too many exploded views"));
|
||||
}
|
||||
let id = next_id(s);
|
||||
let id = next_id(s, cmd)?;
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Exploded View{}", s.doc.assembly.exploded.len() + 1));
|
||||
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
|
||||
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
|
||||
|
||||
@@ -56,7 +56,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
|
||||
// It belongs to the active component (its plane is in that frame) and moves with it.
|
||||
let component = s.active_component;
|
||||
let doc = s.doc_mut();
|
||||
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
|
||||
let id = super::next_free_id(cmd, doc.canvases.iter().map(|c| c.id))?;
|
||||
let base = if decal { "Decal" } else { "Canvas" };
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("{base}{id}"));
|
||||
doc.canvases.push(Canvas {
|
||||
|
||||
@@ -299,3 +299,74 @@ fn a_shell_fillet_does_not_follow_a_gone_edge_onto_a_smooth_seam() {
|
||||
assert!(message.contains("no longer exists"), "{message}");
|
||||
assert!(!message.contains("smooth edge"), "{message}");
|
||||
}
|
||||
|
||||
/// The faces `model.faces` lists for `body`: name, whether it is exact, and centroid.
|
||||
fn listed(s: &mut Session, body: &str) -> Vec<(String, bool, [f64; 3])> {
|
||||
let v = run(s, "model.faces", json!({"body": body}));
|
||||
let faces = v["faces"].as_array().unwrap_or_else(|| panic!("faces of {body}: {v}"));
|
||||
faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let c = |k: usize| f["centroid"][k].as_f64().unwrap_or(f64::NAN);
|
||||
(f["name"].as_str().unwrap_or_default().to_string(), f["exact"].as_bool() == Some(true), [c(0), c(1), c(2)])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The face of `faces` whose centroid is within 0.05 of `at`.
|
||||
fn named_at(faces: &[(String, bool, [f64; 3])], at: [f64; 3]) -> String {
|
||||
let near = |c: &[f64; 3]| (0..3).all(|k| (c[k] - at[k]).abs() < 0.05);
|
||||
let found: Vec<&String> = faces.iter().filter(|(_, _, c)| near(c)).map(|(n, _, _)| n).collect();
|
||||
match found.as_slice() {
|
||||
[n] => (*n).clone(),
|
||||
_ => panic!("faces at {at:?}: {found:?} in {faces:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #1209: an arm with a boss at each end (each boss the radius of the arm's round end under it)
|
||||
/// and a square cut through one. The second boss and the cut are prism booleans sewn slab by
|
||||
/// slab, where the arm's round end and the boss over it are two pieces of one cylinder: every
|
||||
/// face keeps exact history, so the cut's walls are its own four faces and the boss's outer
|
||||
/// cylinder keeps its name, through the fillets on the bore's corners too.
|
||||
#[test]
|
||||
fn a_cut_through_one_of_two_bosses_names_only_its_own_walls() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let (lo0, lo1) = ([16.54527384497186, -18.318724018148217], [159.66891048662592, -12.425397803491872]);
|
||||
let (hi0, hi1) = ([159.66891048662592, 12.425397803491872], [16.54527384497186, 18.318724018148217]);
|
||||
let (j0, j1) = ([162.79697152270225, -13.15736982664994], [162.79697152270225, 13.15736982664994]);
|
||||
let (k0, k1) = ([11.818299035314524, 19.73139143375053], [11.818299035314524, -19.73139143375053]);
|
||||
run(&mut s, "sketch.line", json!({"points": [lo0, lo1]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [159.91576013178314, -18.420317757309917], "start": j0, "end": lo1}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [170.0, 0], "start": j0, "end": j1}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [159.91576013178314, 18.420317757309917], "start": hi0, "end": j1}));
|
||||
run(&mut s, "sketch.line", json!({"points": [hi0, hi1]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [16.95668992023388, 28.310257274511628], "start": k0, "end": hi1}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": k0, "end": k1}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [16.95668992023388, -28.310257274511628], "start": lo0, "end": k1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 16, "body_name": "Arm"}));
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 46.0, "height": 22, "base": [0, 0, 0], "operation": "join", "targets": ["Arm"]}));
|
||||
let boss = named_at(&listed(&mut s, "Arm"), [19.98, -4.24, 19.0]);
|
||||
assert!(boss.starts_with("F3:side"), "{boss}");
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 30.0, "height": 20, "base": [170.0, 0, 0], "operation": "join", "targets": ["Arm"]}));
|
||||
let joined = listed(&mut s, "Arm");
|
||||
assert!(joined.iter().all(|(_, exact, _)| *exact), "{joined:?}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [6.35, 6.35]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all": true, "operation": "cut", "targets": ["Arm"]}));
|
||||
let cut = listed(&mut s, "Arm");
|
||||
assert!(cut.iter().all(|(_, exact, _)| *exact), "{cut:?}");
|
||||
let walls: Vec<&String> = cut.iter().map(|(n, _, _)| n).filter(|n| n.starts_with("F6:")).collect();
|
||||
assert_eq!(walls.len(), 4, "{cut:?}");
|
||||
for (at, line) in [([0.0, -6.35, 11.0], "l1"), ([6.35, 0.0, 11.0], "l2"), ([0.0, 6.35, 11.0], "l3"), ([-6.35, 0.0, 11.0], "l4")] {
|
||||
assert_eq!(named_at(&cut, at), format!("F6:side:{line}"));
|
||||
}
|
||||
assert_eq!(named_at(&cut, [19.98, -4.24, 19.0]), boss);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[6.35, 6.35, 10], [-6.35, 6.35, 10], [-6.35, -6.35, 10], [6.35, -6.35, 10]], "radius": 1.5}));
|
||||
let filleted = listed(&mut s, "Arm");
|
||||
let blends = filleted.iter().filter(|(n, _, _)| n.starts_with("F7:blend:")).count();
|
||||
assert_eq!(blends, 4, "{filleted:?}");
|
||||
assert_eq!(named_at(&filleted, [19.98, -4.24, 19.0]), boss);
|
||||
}
|
||||
@@ -3693,3 +3693,54 @@ fn pattern_copies_added_after_a_rename_keep_their_numbers() {
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "2"}}}));
|
||||
assert_eq!(names(&s), ["X", "Second"]);
|
||||
}
|
||||
|
||||
/// A sketch side drawn as two collinear lines extrudes into one flat face, not two with an edge
|
||||
/// between them (#1788); the face is named from the first line and answers to the second.
|
||||
#[test]
|
||||
fn a_side_drawn_in_two_collinear_pieces_extrudes_into_one_face() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [20, 0], [20, 10], [0, 10], [0, 0]]}));
|
||||
let pieces: Vec<String> = r["created"]["curves"].as_array().into_iter().flatten().take(2).filter_map(|c| c.as_str().map(String::from)).collect();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
let faces = |s: &mut Session| run(s, "model.faces", json!({"body": "Body1"}))["faces"].as_array().cloned().unwrap_or_default();
|
||||
let f = faces(&mut s);
|
||||
assert_eq!(f.len(), 6, "a box has six faces: {f:?}");
|
||||
assert_eq!(run(&mut s, "inspect.measure", json!({}))["bodies"][0]["kernel"]["faces"], 6);
|
||||
let names: Vec<&str> = f.iter().filter_map(|x| x["name"].as_str()).collect();
|
||||
assert!(names.contains(&format!("F2:side:{}", pieces[0]).as_str()), "{names:?}");
|
||||
// The second piece's name answers for the same face, so a reference made when the side was
|
||||
// two faces (an edge of the second piece) still finds its edge.
|
||||
let st = s.model.state();
|
||||
let body = st.body("Body1").unwrap();
|
||||
let edge = format!("F2:side:{}|F2:top", pieces[1]);
|
||||
let (at, _) = solidcraft_doc::naming::find_edge(body, &edge, geom::Vec3::new(15.0, 0.0, 5.0)).unwrap();
|
||||
assert!(at.y.abs() < 1e-9 && (at.z - 5.0).abs() < 1e-9, "{edge} at {at:?}");
|
||||
// A sketch attaches to the joined side as to any side.
|
||||
assert_eq!(run(&mut s, "sketch.create", json!({"plane": {"face": [15, 0, 2.5]}}))["attachment"], "exact_face");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
/// A sketch made on the second piece's face while the side was two faces (as in a design saved
|
||||
/// before #1788) stays attached once the side is straight and extrudes into one face.
|
||||
#[test]
|
||||
fn a_sketch_on_one_piece_stays_attached_when_the_side_joins() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -2], [20, 0], [20, 10], [0, 10], [0, 0]]}));
|
||||
let first = r["created"]["curves"][0].as_str().unwrap_or_default().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
let faces = |s: &mut Session| run(s, "model.faces", json!({"body": "Body1"}))["faces"].as_array().map(Vec::len).unwrap_or(0);
|
||||
assert_eq!(faces(&mut s), 7);
|
||||
assert_eq!(run(&mut s, "sketch.create", json!({"plane": {"face": [15, -1, 2.5]}}))["attachment"], "exact_face");
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [5, 2.5], "radius": 1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
|
||||
run(&mut s, "sketch.move_point", json!({"point": format!("{first}.end"), "to": [10, 0]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(faces(&mut s), 6, "the side is straight now: one face");
|
||||
let sketch = run(&mut s, "timeline.inspect", json!({"feature": "Sketch2"}));
|
||||
assert!(sketch["error"].is_null() && sketch["warning"].is_null(), "{sketch}");
|
||||
}
|
||||
@@ -1326,6 +1326,10 @@ impl Body {
|
||||
/// The reference mesher's angular display mesh: what [`Body::display_mesh_angular`] falls
|
||||
/// back to when no plan can be made.
|
||||
pub fn reference_display_mesh_angular(&self, tol: f64, angle: f64) -> Result<Mesh> {
|
||||
crate::profile::timed(crate::profile::Kind::Tessellate, || self.reference_display_mesh_angular_untimed(tol, angle))
|
||||
}
|
||||
|
||||
fn reference_display_mesh_angular_untimed(&self, tol: f64, angle: f64) -> Result<Mesh> {
|
||||
if let Some((shared, t)) = self.display_of.as_deref()
|
||||
&& let Some(placed) =
|
||||
shared.mesh.get_or_init(|| shared.original.reference_display_mesh_angular(tol, angle).ok()).as_ref().and_then(|m| placed_mesh(m, t))
|
||||
|
||||
@@ -501,3 +501,26 @@ fn bodies_apart_joined_keep_their_faces() {
|
||||
}
|
||||
assert!(faces_of(h).iter().all(|(change, origins)| *change == Change::Kept && origins.len() == 1), "{}", show(h));
|
||||
}
|
||||
|
||||
/// A plate with a round end and a boss of that radius standing on it (#1209): the boss's wall
|
||||
/// and the plate's round end are two pieces of one cylinder, one over the other, meeting along a
|
||||
/// circle at the plate's top. That circle crosses the walls at its level only, so a second boss
|
||||
/// joined to the plate and a cut through the first, both prism booleans sewn slab by slab, keep
|
||||
/// every face of the plate and its boss, with their history.
|
||||
#[test]
|
||||
fn stacked_pieces_of_one_cylinder_keep_their_walls() {
|
||||
let plate =
|
||||
boolean(&boxed(0.0, -6.0, 0.0, 40.0, 6.0, 5.0), &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
let arm = boolean(&plate, &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 8.0).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
verified(&arm, &[&plate, &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 8.0).unwrap()], 0.01);
|
||||
// Standing over the plate's far end, as the boss at the arm's other end does.
|
||||
let second = crate::cylinder(Vec3::new(40.0, 0.0, 0.0), Vec3::Z, 4.0, 7.0).unwrap();
|
||||
let bore = boxed(-3.0, -3.0, -1.0, 3.0, 3.0, 10.0);
|
||||
for (tool, op) in [(&second, BoolOp::Union), (&bore, BoolOp::Cut)] {
|
||||
let r = boolean(&arm, tool, op).unwrap().unwrap();
|
||||
let h = verified(&r, &[&arm, tool], 0.01);
|
||||
for f in 0..arm.face_count() {
|
||||
assert!(!from(h, face(0, f)).is_empty(), "{op:?}: face {f} of the plate and boss is lost\n{}", show(h));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,19 @@ pub(crate) fn frame_ok(plane: &Plane) -> bool {
|
||||
|
||||
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
|
||||
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
||||
extrude_with(plane, regions, start, end, false)
|
||||
}
|
||||
|
||||
/// [`extrude`] with each run of profile lines that continue one another in a straight line (a side
|
||||
/// drawn in two pieces, a mirrored half meeting its mirror image) swept into one side face over
|
||||
/// all its pieces, rather than coplanar faces with an edge between them (#1788). The side's
|
||||
/// history lists every piece under it. For the Extrude feature; other builders (sheet metal's
|
||||
/// flat pattern, tools) keep a face per segment.
|
||||
pub fn extrude_whole_sides(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
||||
extrude_with(plane, regions, start, end, true)
|
||||
}
|
||||
|
||||
fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, whole_sides: bool) -> Result<Vec<Body>> {
|
||||
if !frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
|
||||
}
|
||||
@@ -206,7 +219,19 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
for r in regions {
|
||||
region_ok(r)?;
|
||||
let base = plane.offset(lo);
|
||||
let profile = region_wires(&base, &oriented(r)?)?;
|
||||
let pieces = oriented(r)?;
|
||||
let numbered = if whole_sides { pieces.map_loops(|l| l.merged_lines(continues_straight)) } else { pieces.clone() };
|
||||
// A joined side's source is its first piece, a segment of the profile as drawn (the side
|
||||
// lies along it): the sketch curve it is certified and named by, as when it stood alone.
|
||||
let first_piece = |side: &ProfileEdge| {
|
||||
pieces
|
||||
.loops()
|
||||
.flat_map(|l| l.lp.segs.iter())
|
||||
.copied()
|
||||
.find(|piece| side.numbers.len() > 1 && piece.start() == side.segment.start())
|
||||
.unwrap_or(side.segment)
|
||||
};
|
||||
let profile = region_wires(&base, &numbered)?;
|
||||
let wires = wires_of(&profile);
|
||||
let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?;
|
||||
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
|
||||
@@ -263,7 +288,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: side.segment, plane: basis, coalesced: false }),
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: first_piece(side), plane: basis, coalesced: false }),
|
||||
Some(record::profile(&side.numbers)),
|
||||
)
|
||||
} else if Some(*id) == end {
|
||||
@@ -285,6 +310,14 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `t` carries on from `s` in the same straight line: the turn at their meeting point is within
|
||||
/// rounding of none, measured against both lengths.
|
||||
fn continues_straight(s: &Seg2, t: &Seg2) -> bool {
|
||||
let (u, v) = (s.end() - s.start(), t.end() - t.start());
|
||||
let (lu, lv) = (u.len(), v.len());
|
||||
lu > 0.0 && lv > 0.0 && s.end().dist(t.start()) <= 1e-9 * (lu + lv) && u.dot(v) > 0.0 && u.cross(v).abs() <= 1e-9 * lu * lv
|
||||
}
|
||||
|
||||
/// Choose the per-profile axis sign whose positive rotation follows the sketch normal.
|
||||
pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2, axis_dir: Vec2) -> Result<Vec3> {
|
||||
if !axis_origin.is_finite() || !axis_dir.is_finite() || !plane.origin.is_finite() || !plane.x.is_finite() || !plane.y.is_finite() {
|
||||
|
||||
@@ -244,3 +244,36 @@ fn face_sources_follow_kept_split_and_agreeing_merged_faces() {
|
||||
let s = h.face_sources(&[&a]);
|
||||
assert_eq!(s, vec![src(0), src(1), None, src(2), None]);
|
||||
}
|
||||
|
||||
/// A side drawn in two collinear pieces (here the bottom edge, split at x = 10) sweeps into one
|
||||
/// side face over both pieces, not two coplanar faces with an edge between them (#1788).
|
||||
#[test]
|
||||
fn collinear_profile_lines_extrude_into_one_side_face() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let pts = [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y));
|
||||
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
|
||||
// Plain `extrude` keeps a face per segment (sheet metal's flat pattern relies on it).
|
||||
assert_eq!(crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0.0, 5.0).unwrap()[0].face_count(), 7);
|
||||
let body = crate::extrude_whole_sides(&Plane::XY, &[region], 0.0, 5.0).unwrap().pop().unwrap();
|
||||
assert_eq!(body.face_count(), 6, "a box has six faces");
|
||||
assert!((crate::measure(&body).unwrap().volume - 1000.0).abs() < 1e-9);
|
||||
let h = body.history().unwrap();
|
||||
h.check(&body, &[]).unwrap();
|
||||
let mut sides: Vec<Vec<FaceOrigin>> =
|
||||
h.faces.iter().map(|f| f.origins.clone()).filter(|o| matches!(o.first(), Some(FaceOrigin::Profile { .. }))).collect();
|
||||
sides.sort();
|
||||
assert_eq!(
|
||||
sides,
|
||||
vec![
|
||||
vec![FaceOrigin::Profile { segment: 0 }, FaceOrigin::Profile { segment: 1 }],
|
||||
vec![FaceOrigin::Profile { segment: 2 }],
|
||||
vec![FaceOrigin::Profile { segment: 3 }],
|
||||
vec![FaceOrigin::Profile { segment: 4 }],
|
||||
],
|
||||
"the bottom side is generated by both its pieces"
|
||||
);
|
||||
// A corner is not a straight run: an L-shaped step keeps its faces.
|
||||
let step = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (20.0, 5.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y));
|
||||
let body = crate::extrude_whole_sides(&Plane::XY, &[Region2 { outer: Loop2::polygon(&step), holes: vec![] }], 0.0, 5.0).unwrap().pop().unwrap();
|
||||
assert_eq!(body.face_count(), 8);
|
||||
}
|
||||
@@ -151,8 +151,8 @@ pub use boundary::{Boundary, boundary_patch};
|
||||
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve,
|
||||
revolve_whole, sphere, sweep, sweep_with, torus,
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, extrude_whole_sides, loft,
|
||||
path_polyline, revolve, revolve_whole, sphere, sweep, sweep_with, torus,
|
||||
};
|
||||
pub use clearance::{Clearance, Occupancy};
|
||||
pub use coaxial_overlap::{CoaxialOverlap, coaxial_overlap};
|
||||
|
||||
@@ -343,6 +343,10 @@ fn faceted_polys(b: &Body, tol: f64, operand: usize) -> Option<Vec<Poly>> {
|
||||
/// triangles of `b`; an edge between facets of one face of `a`, or between triangles of `b`,
|
||||
/// has no origin.
|
||||
pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Option<Body>> {
|
||||
crate::profile::timed(crate::profile::Kind::Boolean, || faceted_boolean_untimed(a, b, op, tol))
|
||||
}
|
||||
|
||||
fn faceted_boolean_untimed(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return Err(KernelError::Invalid("facet tolerance".into()));
|
||||
|
||||
@@ -435,6 +435,9 @@ pub(crate) struct Index {
|
||||
points: Vec<Vec3>,
|
||||
/// Per edge (edge order): whether it is a line.
|
||||
lines: Vec<bool>,
|
||||
/// Per edge (edge order) that is not a line: the points its curve lies within (see
|
||||
/// [`control_hull`]); `None` where they are not known.
|
||||
hulls: Vec<Option<Vec<Vec3>>>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
@@ -451,6 +454,7 @@ impl Index {
|
||||
}
|
||||
}
|
||||
let lines = edges.iter().map(|e| matches!(e.curve(), mt::Curve::Line(_))).collect();
|
||||
let hulls = edges.iter().map(|e| control_hull(&e.curve())).collect();
|
||||
let mut between: std::collections::HashMap<(usize, usize), Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, fs) in bounds.iter().enumerate() {
|
||||
match fs.as_slice() {
|
||||
@@ -464,7 +468,7 @@ impl Index {
|
||||
.zip(bounds)
|
||||
.map(|(e, fs)| (fs, vertices.get(&e.absolute_front().id()).copied(), vertices.get(&e.absolute_back().id()).copied()))
|
||||
.collect();
|
||||
Index { operand, edges: crate::record::edge_index(&b.solid), bounds, between, vertices, points, lines }
|
||||
Index { operand, edges: crate::record::edge_index(&b.solid), bounds, between, vertices, points, lines, hulls }
|
||||
}
|
||||
|
||||
/// The tag of the segments read off edge `e` (as it runs in a wire) of cap face `cap`.
|
||||
@@ -517,7 +521,7 @@ struct Stations {
|
||||
/// Each wall's highest level.
|
||||
tops: std::collections::HashMap<usize, f64>,
|
||||
/// Per wall, the edges across it: the other wall and the edge's lowest and highest levels.
|
||||
/// A curve other than a line spans every level.
|
||||
/// A curve other than a line spans every level, unless it lies at one level.
|
||||
cross: std::collections::HashMap<usize, Vec<(usize, f64, f64)>>,
|
||||
}
|
||||
|
||||
@@ -540,7 +544,13 @@ impl Stations {
|
||||
let (h, k) = (plane.height(*p), plane.height(*q));
|
||||
(h.min(k), h.max(k))
|
||||
}
|
||||
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
// A curve at one level (where a piece of a wall's surface stacked on another
|
||||
// meets it: a boss's cylinder over the arc of the plate it stands on) crosses
|
||||
// the walls there only. Any other spans every level.
|
||||
_ => match index.hulls.get(i).and_then(Option::as_deref).map(|hull| level_span(hull, plane)) {
|
||||
Some((lo, hi)) if hi - lo < tol => (lo, hi),
|
||||
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
},
|
||||
};
|
||||
cross.entry(f).or_default().push((g, lo, hi));
|
||||
cross.entry(g).or_default().push((f, lo, hi));
|
||||
@@ -590,6 +600,35 @@ impl Stations {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most control points [`control_hull`] reads of one curve.
|
||||
const MAX_HULL_POINTS: usize = 4096;
|
||||
|
||||
/// The points curve `c` lies within: a B-spline's control points, or a NURBS curve's where its
|
||||
/// weights are positive (the curve then lies in their convex hull). `None` for a line, an
|
||||
/// intersection curve, other weights or too many points.
|
||||
fn control_hull(c: &mt::Curve) -> Option<Vec<Vec3>> {
|
||||
match c {
|
||||
mt::Curve::BSplineCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => Some(c.control_points().iter().map(|p| from_p3(*p)).collect()),
|
||||
mt::Curve::NurbsCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => {
|
||||
c.control_points().iter().map(|p| (p.w > 0.0 && p.w.is_finite()).then(|| Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w))).collect()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest and highest levels of `points` (infinite where there are none, or one isn't
|
||||
/// finite).
|
||||
fn level_span(points: &[Vec3], plane: &Plane) -> (f64, f64) {
|
||||
let span = points
|
||||
.iter()
|
||||
.map(|p| plane.height(*p))
|
||||
.try_fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), h| h.is_finite().then(|| (lo.min(h), hi.max(h))));
|
||||
match span {
|
||||
Some((lo, hi)) if lo <= hi => (lo, hi),
|
||||
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest level of face `f`'s vertices.
|
||||
fn top_of(f: &mt::Face, plane: &Plane) -> f64 {
|
||||
f.vertex_iter().map(|v| plane.height(from_p3(v.point()))).fold(f64::NEG_INFINITY, f64::max)
|
||||
|
||||
@@ -133,4 +133,18 @@ mod tests {
|
||||
let st = STATE.with(Cell::get);
|
||||
assert!(!st.on && !st.busy);
|
||||
}
|
||||
#[test]
|
||||
fn a_faceted_boolean_is_boolean_time_and_a_reference_mesh_tessellation() {
|
||||
use crate::{BoolOp, box_solid, faceted_boolean};
|
||||
use solidcraft_geom::Vec3;
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let tool = box_solid(Vec3::new(5.0, 5.0, 5.0), Vec3::new(15.0, 15.0, 15.0)).unwrap().tessellate(0.1).unwrap();
|
||||
// The thread groove's route: the faceting it does first is the boolean's time too.
|
||||
let (r, t) = capture(|| faceted_boolean(&a, &tool, BoolOp::Cut, 0.01));
|
||||
assert!(r.unwrap().is_some());
|
||||
assert!(t.boolean_ms > 0.0 && t.tessellate_ms == 0.0, "{t:?}");
|
||||
let (m, t) = capture(|| a.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE));
|
||||
assert!(!m.unwrap().triangles.is_empty());
|
||||
assert!(t.tessellate_ms > 0.0 && t.boolean_ms == 0.0, "{t:?}");
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ pub fn initialize_params(version: &str) -> Value {
|
||||
"auth": {"terminal": true},
|
||||
"_meta": {"terminal-auth": true},
|
||||
},
|
||||
"clientInfo": {"name": "solidcraft", "title": "SolidCraft", "version": version},
|
||||
"clientInfo": {"name": "onecad", "title": "OneCAD", "version": version},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ impl AcpBackend {
|
||||
Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }),
|
||||
Incoming::ModelChanged(m) => out.push(Event::Models { current: m, available: Vec::new() }),
|
||||
Incoming::Unsupported { rpc, method } => {
|
||||
self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`")));
|
||||
self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("OneCAD does not serve `{method}`")));
|
||||
}
|
||||
Incoming::Ignored => {}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ impl Worker {
|
||||
|
||||
/// One MCP request to the server; its `result`.
|
||||
fn mcp_call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let Some(m) = self.mcp.as_ref() else { return Err("no SolidCraft tools in this session".into()) };
|
||||
let Some(m) = self.mcp.as_ref() else { return Err("no OneCAD tools in this session".into()) };
|
||||
self.next += 1;
|
||||
let id = self.next;
|
||||
m.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
||||
@@ -202,9 +202,9 @@ impl Worker {
|
||||
}
|
||||
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
Ok(FromAgent::Exited(_)) => return Err("the SolidCraft tools stopped".into()),
|
||||
Ok(FromAgent::Exited(_)) => return Err("the OneCAD tools stopped".into()),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => {}
|
||||
Err(_) => return Err("the SolidCraft tools did not answer".into()),
|
||||
Err(_) => return Err("the OneCAD tools did not answer".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,8 @@ pub struct AgentChat {
|
||||
pub agent_id: Option<String>,
|
||||
/// The agent the running backend is (for permissions and "always allow").
|
||||
running_id: String,
|
||||
/// The popped-out chat window was given its saved place (it is open); see `panel::window_builder`.
|
||||
pub(crate) window_placed: bool,
|
||||
/// The agent's own modes, and the current one.
|
||||
modes: Vec<(String, String)>,
|
||||
mode: String,
|
||||
@@ -1131,6 +1133,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
|
||||
if let Some(m) = p.get("minimized").and_then(Value::as_bool) {
|
||||
app.preferences.agent_chat_minimized = m;
|
||||
}
|
||||
if let Some(w) = p.get("popped").and_then(Value::as_bool) {
|
||||
app.preferences.agent_chat_popped = w;
|
||||
}
|
||||
if let Some(w) = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()) {
|
||||
app.preferences.agent_chat_width = (w as f32).clamp(panel::MIN_WIDTH, panel::MAX_WIDTH);
|
||||
panel::forget_width(ctx);
|
||||
@@ -1233,6 +1238,8 @@ pub fn status(app: &SolveApp) -> Value {
|
||||
"open": c.open,
|
||||
"side": app.preferences.agent_chat_side,
|
||||
"minimized": app.preferences.agent_chat_minimized,
|
||||
"popped": app.preferences.agent_chat_popped,
|
||||
"window": app.preferences.agent_chat_window,
|
||||
"width": app.preferences.agent_chat_width,
|
||||
"state": c.state().id(),
|
||||
"agent": c.agent,
|
||||
|
||||
@@ -58,6 +58,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||||
toggle(app);
|
||||
}
|
||||
// A window that isn't drawn this frame closes; the next one opens at the saved place.
|
||||
if !(app.agent_chat.open && app.preferences.agent_chat_popped) {
|
||||
app.agent_chat.window_placed = false;
|
||||
}
|
||||
// New chat, when the chat has focus (its input, or the pointer over the panel).
|
||||
let in_chat = ctx.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")))
|
||||
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
|
||||
@@ -72,6 +76,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, &ctx);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if app.preferences.agent_chat_popped {
|
||||
window(app, &ctx);
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let left = app.preferences.agent_chat_side == "left";
|
||||
let side = |id: Id| if left { egui::Panel::left(id) } else { egui::Panel::right(id) };
|
||||
@@ -107,7 +116,78 @@ fn request_new_chat(app: &mut SolveApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small drawn button: `kind` is `minimize`, `close`, `stopturn` or `expand`.
|
||||
/// A saved window place (`[x, y, width, height]`) that can be used: finite, a usable size, and
|
||||
/// not absurdly far away. `x` and `y` may be negative: a screen left of or above the main one.
|
||||
pub fn window_place(saved: Option<[f32; 4]>) -> Option<([f32; 2], [f32; 2])> {
|
||||
let [x, y, w, h] = saved?;
|
||||
let usable = [x, y, w, h].iter().all(|v| v.is_finite())
|
||||
&& (MIN_WIDTH..=10_000.0).contains(&w)
|
||||
&& (200.0..=10_000.0).contains(&h)
|
||||
&& x.abs() <= 100_000.0
|
||||
&& y.abs() <= 100_000.0;
|
||||
usable.then_some(([x, y], [w, h]))
|
||||
}
|
||||
|
||||
/// The chat window's settings. The saved place and size go only to a window that is opening:
|
||||
/// egui turns a changed place into a move, and the system rounds what it is given, so giving it
|
||||
/// every frame made the window chase its own rounding (it shook a pixel up and down).
|
||||
pub fn window_builder(app: &SolveApp) -> egui::ViewportBuilder {
|
||||
let title = format!("{} — {}", app.session.doc.name, tr("ui.agent_chat"));
|
||||
let builder = egui::ViewportBuilder::default().with_title(title).with_min_inner_size([MIN_WIDTH, 320.0]);
|
||||
if app.agent_chat.window_placed {
|
||||
return builder;
|
||||
}
|
||||
match window_place(app.preferences.agent_chat_window) {
|
||||
Some((pos, size)) => builder.with_position(pos).with_inner_size(size),
|
||||
None => builder.with_inner_size([app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH), 720.0]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat in its own window, named after the design: a native window on the desktop, which can
|
||||
/// go to another screen, or a window floating inside the app where the system can't open one.
|
||||
/// The system's close button closes the chat (it opens there again); Dock puts it back.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn window(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let builder = window_builder(app);
|
||||
app.agent_chat.window_placed = true;
|
||||
// The side panel isn't drawn: its place no longer counts as "in the chat".
|
||||
app.agent_chat.panel_rect = None;
|
||||
ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("sc_agent_chat_window"), builder, |ui, class| {
|
||||
let t = Tokens::get();
|
||||
let frame = egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6));
|
||||
let r = egui::CentralPanel::default().frame(frame).show(ui, |ui| body(app, ui, false));
|
||||
if class == egui::ViewportClass::EmbeddedWindow {
|
||||
// Floating inside the app: its handles are the main window's, as the panel's are.
|
||||
crate::scenario::publish_rect("agent_chat:window", r.response.rect);
|
||||
app.agent_chat.panel_rect = Some(r.response.rect);
|
||||
return;
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||||
toggle(app);
|
||||
}
|
||||
// The whole window is the chat, so New chat works anywhere in it.
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
|
||||
request_new_chat(app);
|
||||
}
|
||||
let (outer, inner, close) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect, i.viewport().close_requested()));
|
||||
if let (Some(o), Some(n)) = (outer, inner) {
|
||||
app.preferences.agent_chat_window = Some([o.min.x, o.min.y, n.width(), n.height()]);
|
||||
}
|
||||
if close {
|
||||
app.agent_chat.open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the chat's own window should become active because the pointer is over its text box:
|
||||
/// only when it is in a window of its own that isn't active and the app's main window is (never
|
||||
/// taking the focus from another app).
|
||||
pub fn take_focus_for_text(popped: bool, over_text: bool, chat_active: Option<bool>, main_active: Option<bool>) -> bool {
|
||||
popped && over_text && chat_active == Some(false) && main_active == Some(true)
|
||||
}
|
||||
|
||||
/// A small drawn button: `kind` is `minimize`, `close`, `newchat`, `stopturn`, `pop_out` or `dock`.
|
||||
fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
@@ -116,13 +196,29 @@ fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
|
||||
}
|
||||
let c = r.center();
|
||||
let s = Stroke::new(1.4, t.text);
|
||||
let p = ui.painter();
|
||||
match kind {
|
||||
"minimize" => {
|
||||
ui.painter().line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s);
|
||||
p.line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s);
|
||||
}
|
||||
"close" => {
|
||||
ui.painter().line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
|
||||
ui.painter().line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
|
||||
p.line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
|
||||
p.line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
|
||||
}
|
||||
// A window with an arrow leaving it by its top right corner.
|
||||
"pop_out" => {
|
||||
p.line_segment([c + vec2(-1.0, -5.0), c + vec2(-5.0, -5.0)], s);
|
||||
p.line_segment([c + vec2(-5.0, -5.0), c + vec2(-5.0, 5.0)], s);
|
||||
p.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, 5.0)], s);
|
||||
p.line_segment([c + vec2(5.0, 5.0), c + vec2(5.0, 1.0)], s);
|
||||
p.line_segment([c + vec2(-1.0, 1.0), c + vec2(5.5, -5.5)], s);
|
||||
p.line_segment([c + vec2(1.5, -5.5), c + vec2(5.5, -5.5)], s);
|
||||
p.line_segment([c + vec2(5.5, -5.5), c + vec2(5.5, -1.5)], s);
|
||||
}
|
||||
// A window with its right side filled: the panel docked.
|
||||
"dock" => {
|
||||
p.rect_stroke(Rect::from_center_size(c, vec2(11.0, 10.0)), 1.0, s, egui::StrokeKind::Middle);
|
||||
p.rect_filled(Rect::from_min_max(c + vec2(1.5, -5.0), c + vec2(5.5, 5.0)), 0.0, t.text);
|
||||
}
|
||||
"newchat" => {
|
||||
// A speech bubble with a plus.
|
||||
@@ -179,16 +275,20 @@ fn strip(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
/// The header: a grip to drag the panel to the other side, the title and state, Connect or Stop,
|
||||
/// Minimise and Close.
|
||||
/// Pop out (or Dock, in its own window), Minimise and Close. In its own window the system moves
|
||||
/// and minimises it, so there is no grip and no Minimise.
|
||||
fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
let t = Tokens::get();
|
||||
let state = app.agent_chat.state();
|
||||
let popped = app.preferences.agent_chat_popped;
|
||||
ui.horizontal(|ui| {
|
||||
let title = {
|
||||
let (grip, _) = ui.allocate_exact_size(vec2(10.0, 18.0), Sense::hover());
|
||||
for row in 0..3 {
|
||||
for col in 0..2 {
|
||||
ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim);
|
||||
let (grip, _) = ui.allocate_exact_size(vec2(if popped { 0.0 } else { 10.0 }, 18.0), Sense::hover());
|
||||
if !popped {
|
||||
for row in 0..3 {
|
||||
for col in 0..2 {
|
||||
ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
let l = ui.label(RichText::new(tr("ui.agent_chat")).strong().size(14.0));
|
||||
@@ -203,14 +303,16 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
}
|
||||
ui.label(RichText::new(s).color(state_color(state)).small());
|
||||
// Drag the header across the window to dock on the other side.
|
||||
let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag());
|
||||
crate::scenario::publish_handle("agent_chat:grip", title.center());
|
||||
dock_drag(app, ui, &drag, left);
|
||||
if !popped {
|
||||
let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag());
|
||||
crate::scenario::publish_handle("agent_chat:grip", title.center());
|
||||
dock_drag(app, ui, &drag, left);
|
||||
}
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() {
|
||||
app.agent_chat.open = false;
|
||||
}
|
||||
if glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() {
|
||||
if !popped && glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() {
|
||||
app.preferences.agent_chat_minimized = true;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -224,6 +326,13 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
request_new_chat(app);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (kind, tip) = if popped { ("dock", "ui.agent_chat_dock") } else { ("pop_out", "ui.agent_chat_pop_out") };
|
||||
if glyph_button(ui, kind, tr(tip)).clicked() {
|
||||
app.preferences.agent_chat_popped = !popped;
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let b = if state.running() {
|
||||
ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip"))
|
||||
@@ -345,6 +454,13 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
};
|
||||
let r = ui.add(edit);
|
||||
crate::scenario::publish_rect("agent_chat:input", r.rect);
|
||||
// macOS shows a window's cursors only while it is the active window: over the text box of a
|
||||
// chat window that isn't, the pointer stays an arrow. Coming from the main window, the chat
|
||||
// window becomes active, so the text cursor shows and the first click places the caret.
|
||||
let (chat_active, main_active) = (ui.ctx().input(|i| i.viewport().focused), ui.ctx().input_for(egui::ViewportId::ROOT, |i| i.viewport().focused));
|
||||
if take_focus_for_text(app.preferences.agent_chat_popped, r.hovered(), chat_active, main_active) {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
|
||||
if esc {
|
||||
cancel(app);
|
||||
|
||||
@@ -183,12 +183,19 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
egui::Grid::new("sc_ai_panel_grid").num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| {
|
||||
ui.label(tr("ui.agent_chat_side"));
|
||||
ui.horizontal(|ui| {
|
||||
let side = &mut app.preferences.agent_chat_side;
|
||||
if ui.radio(side == "left", tr("ui.agent_chat_left")).clicked() {
|
||||
*side = "left".into();
|
||||
let p = &mut app.preferences;
|
||||
let (left, popped) = (p.agent_chat_side == "left", p.agent_chat_popped);
|
||||
if ui.radio(left && !popped, tr("ui.agent_chat_left")).clicked() {
|
||||
p.agent_chat_side = "left".into();
|
||||
p.agent_chat_popped = false;
|
||||
}
|
||||
if ui.radio(side != "left", tr("ui.agent_chat_right")).clicked() {
|
||||
*side = "right".into();
|
||||
if ui.radio(!left && !popped, tr("ui.agent_chat_right")).clicked() {
|
||||
p.agent_chat_side = "right".into();
|
||||
p.agent_chat_popped = false;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if ui.radio(popped, tr("ui.agent_chat_own_window")).on_hover_text(tr("ui.agent_chat_pop_out")).clicked() {
|
||||
p.agent_chat_popped = true;
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
@@ -234,6 +234,109 @@ fn ai_features_and_the_toolbar_button_are_on_by_default() {
|
||||
assert!(p.ai_features && p.ai_toolbar_button);
|
||||
}
|
||||
|
||||
/// Pop out puts the chat in its own window (a native window on the desktop, which can go to
|
||||
/// another screen; embedded here, where the test context has one viewport), and Dock puts it back
|
||||
/// in the side panel. The control channel does the same.
|
||||
#[test]
|
||||
fn the_chat_pops_out_into_its_own_window_and_docks_back() {
|
||||
use crate::scenario::{handle_at, rect_of};
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.agent_chat", json!({"open": true}));
|
||||
h.frames(3);
|
||||
assert!(rect_of("agent_chat:panel").is_some() && rect_of("agent_chat:window").is_none(), "docked by default");
|
||||
assert!(!h.app.preferences.agent_chat_popped);
|
||||
let pop = handle_at("agent_chat:pop_out").expect("a pop-out button in the header");
|
||||
h.call("ui.click", json!({"x": pop.x, "y": pop.y}));
|
||||
h.frames(3);
|
||||
assert!(h.app.preferences.agent_chat_popped);
|
||||
assert!(rect_of("agent_chat:panel").is_none(), "the side panel is gone");
|
||||
assert!(rect_of("agent_chat:window").is_some(), "the chat is in its own window");
|
||||
assert_eq!(status(&h.app)["popped"], json!(true));
|
||||
// A window has its own minimise; the strip and the side drag are the docked panel's.
|
||||
assert!(handle_at("agent_chat:minimize").is_none() && handle_at("agent_chat:grip").is_none());
|
||||
let dock = handle_at("agent_chat:dock").expect("a dock button in the popped-out header");
|
||||
h.call("ui.click", json!({"x": dock.x, "y": dock.y}));
|
||||
h.frames(3);
|
||||
assert!(!h.app.preferences.agent_chat_popped);
|
||||
assert!(rect_of("agent_chat:panel").is_some() && rect_of("agent_chat:window").is_none(), "back in the side panel");
|
||||
h.call("ui.agent_chat", json!({"popped": true}));
|
||||
h.frames(3);
|
||||
assert!(rect_of("agent_chat:window").is_some() && rect_of("agent_chat:panel").is_none());
|
||||
// Closing the chat keeps where it lives: it opens popped out again.
|
||||
h.call("ui.agent_chat", json!({"open": false}));
|
||||
h.frames(2);
|
||||
assert!(rect_of("agent_chat:window").is_none());
|
||||
h.call("ui.agent_chat", json!({"open": true}));
|
||||
h.frames(3);
|
||||
assert!(rect_of("agent_chat:window").is_some());
|
||||
}
|
||||
|
||||
/// The saved place is given to the window once, when it opens; after that the system owns it.
|
||||
/// Given every frame, it chased the system's own rounding: a window that shook a pixel up and
|
||||
/// down (686, 687, 686…) on a Retina screen.
|
||||
#[test]
|
||||
fn the_window_place_is_given_once_when_it_opens() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.preferences.agent_chat_window = Some([1967.0, 160.0, 569.0, 686.0]);
|
||||
let first = panel::window_builder(&app);
|
||||
assert_eq!(first.position, Some(egui::pos2(1967.0, 160.0)));
|
||||
assert_eq!(first.inner_size, Some(egui::vec2(569.0, 686.0)));
|
||||
app.agent_chat.window_placed = true;
|
||||
app.preferences.agent_chat_window = Some([1967.0, 160.0, 569.0, 687.0]);
|
||||
let later = panel::window_builder(&app);
|
||||
assert_eq!((later.position, later.inner_size), (None, None), "nothing to move or resize once it is open");
|
||||
assert!(later.title.is_some());
|
||||
// Docked or closed, the next pop-out opens at the saved place again.
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.agent_chat", json!({"open": true, "popped": true}));
|
||||
h.frames(2);
|
||||
assert!(h.app.agent_chat.window_placed);
|
||||
h.call("ui.agent_chat", json!({"popped": false}));
|
||||
h.frames(2);
|
||||
assert!(!h.app.agent_chat.window_placed);
|
||||
h.call("ui.agent_chat", json!({"popped": true}));
|
||||
h.frames(2);
|
||||
h.call("ui.agent_chat", json!({"open": false}));
|
||||
h.frames(2);
|
||||
assert!(!h.app.agent_chat.window_placed);
|
||||
}
|
||||
|
||||
/// macOS shows the text cursor only in the active window: the chat's own window becomes active
|
||||
/// when the pointer comes over its text box from the app's main window, and never takes the focus
|
||||
/// from another app.
|
||||
#[test]
|
||||
fn the_chat_window_takes_the_focus_for_its_text_box_from_the_main_window_only() {
|
||||
assert!(panel::take_focus_for_text(true, true, Some(false), Some(true)));
|
||||
assert!(!panel::take_focus_for_text(true, true, Some(false), Some(false)), "another app is active");
|
||||
assert!(!panel::take_focus_for_text(true, true, Some(true), Some(false)), "already active");
|
||||
assert!(!panel::take_focus_for_text(true, false, Some(false), Some(true)), "not over the text box");
|
||||
assert!(!panel::take_focus_for_text(false, true, Some(false), Some(true)), "docked: the main window shows it");
|
||||
assert!(!panel::take_focus_for_text(true, true, None, Some(true)), "unknown: leave it");
|
||||
}
|
||||
|
||||
/// Whether the chat is popped out, and its window's place and size, are preferences; ones saved
|
||||
/// before they existed load docked, and an unusable saved place is ignored.
|
||||
#[test]
|
||||
fn the_popped_out_window_and_its_place_are_remembered() {
|
||||
use crate::prefs::Prefs;
|
||||
let mut p = Prefs::default();
|
||||
assert!(!p.agent_chat_popped && p.agent_chat_window.is_none());
|
||||
p.agent_chat_popped = true;
|
||||
p.agent_chat_window = Some([-1600.0, 80.0, 420.0, 700.0]);
|
||||
let back: Prefs = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
|
||||
assert!(back.agent_chat_popped);
|
||||
assert_eq!(back.agent_chat_window, Some([-1600.0, 80.0, 420.0, 700.0]), "a place on a screen left of the main one is kept");
|
||||
let mut old = serde_json::to_value(Prefs::default()).unwrap();
|
||||
old.as_object_mut().unwrap().remove("agent_chat_popped");
|
||||
old.as_object_mut().unwrap().remove("agent_chat_window");
|
||||
let old: Prefs = serde_json::from_value(old).unwrap();
|
||||
assert!(!old.agent_chat_popped && old.agent_chat_window.is_none());
|
||||
assert_eq!(panel::window_place(Some([10.0, 20.0, 400.0, 600.0])), Some(([10.0, 20.0], [400.0, 600.0])));
|
||||
for bad in [[f32::NAN, 0.0, 400.0, 600.0], [0.0, 0.0, 10.0, 600.0], [0.0, 0.0, 400.0, f32::INFINITY], [1e9, 0.0, 400.0, 600.0]] {
|
||||
assert_eq!(panel::window_place(Some(bad)), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// "Ask every time": a modelling command waits for Allow; "Always allow" lets the same command
|
||||
/// through from then on for this agent. "Allow everything": even a save runs without a question.
|
||||
#[test]
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn announce(app: &mut SolveApp, fresh: &[Summary]) {
|
||||
}
|
||||
app.crashes.open = true;
|
||||
app.crashes.refresh();
|
||||
app.set_status("SolidCraft closed unexpectedly or hit an internal error last time. A crash report was saved (Help > Crash Reports…).", true);
|
||||
app.set_status("OneCAD closed unexpectedly or hit an internal error last time. A crash report was saved (Help > Crash Reports…).", true);
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn copy(app: &mut SolveApp) -> bool {
|
||||
let Ok(out) = app.run("sketch.copy", json!({ "entities": entities })) else { return false };
|
||||
let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0);
|
||||
let k = out.get("constraints").and_then(Value::as_u64).unwrap_or(0);
|
||||
let text = format!("SolidCraft sketch geometry: {n} entities, {k} constraints and dimensions");
|
||||
let text = format!("OneCAD sketch geometry: {n} entities, {k} constraints and dimensions");
|
||||
app.set_status(format!("Copied {n} sketch entities with {k} constraints and dimensions"), false);
|
||||
leave_text(text);
|
||||
return true;
|
||||
@@ -74,7 +74,7 @@ pub fn copy(app: &mut SolveApp) -> bool {
|
||||
let Ok(out) = app.run("timeline.copy", json!({ "features": features })) else { return false };
|
||||
let names: Vec<&str> = out.get("copied").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
app.set_status(format!("Copied {}", names.join(", ")), false);
|
||||
leave_text(format!("SolidCraft features: {}", names.join(", ")));
|
||||
leave_text(format!("OneCAD features: {}", names.join(", ")));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn cut(app: &mut SolveApp) -> bool {
|
||||
let Ok(out) = app.run("sketch.cut", json!({ "entities": entities })) else { return false };
|
||||
let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0);
|
||||
app.set_status(format!("Cut {n} sketch entities"), false);
|
||||
leave_text(format!("SolidCraft sketch geometry: {n} entities"));
|
||||
leave_text(format!("OneCAD sketch geometry: {n} entities"));
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ fn copy_leaves_text_on_the_system_clipboard_so_paste_can_follow() {
|
||||
_ => None,
|
||||
}));
|
||||
}
|
||||
assert!(copied.iter().any(|t| t.starts_with("SolidCraft sketch geometry")), "{copied:?}");
|
||||
assert!(copied.iter().any(|t| t.starts_with("OneCAD sketch geometry")), "{copied:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -354,7 +354,7 @@ fn start(app: &mut SolveApp) {
|
||||
let picture = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| partial_picture(&partial.world, &stop))).ok().flatten();
|
||||
if solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some() {
|
||||
eprintln!(
|
||||
"SolidCraft partial picture: features={} bodies={} drawn={} ms={:.1}",
|
||||
"OneCAD partial picture: features={} bodies={} drawn={} ms={:.1}",
|
||||
partial.features,
|
||||
partial.world.bodies.len(),
|
||||
picture.is_some(),
|
||||
@@ -380,7 +380,7 @@ fn start(app: &mut SolveApp) {
|
||||
if let Some(picture) = picture.filter(|_| !progress.is_cancelled()) {
|
||||
let _ = preview_tx.send(picture);
|
||||
if solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some() {
|
||||
eprintln!("SolidCraft saved preview: ready_ms={:.3}", preview_began.elapsed().as_secs_f64() * 1000.0);
|
||||
eprintln!("OneCAD saved preview: ready_ms={:.3}", preview_began.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -736,7 +736,7 @@ fn compute(
|
||||
let image = if thumbnail { crate::home::remember(&session) } else { None };
|
||||
if profile {
|
||||
eprintln!(
|
||||
"SolidCraft open phases: geometry_ms={geometry_ms:.3} placement_ms={placement_ms:.3} tessellation_ms={tessellation_ms:.3} display_warmup_ms={view_ms:.3} thumbnail_ms={:.3} total_ms={:.3}",
|
||||
"OneCAD open phases: geometry_ms={geometry_ms:.3} placement_ms={placement_ms:.3} tessellation_ms={tessellation_ms:.3} display_warmup_ms={view_ms:.3} thumbnail_ms={:.3} total_ms={:.3}",
|
||||
thumbnail_began.elapsed().as_secs_f64() * 1000.0,
|
||||
began.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
+14
-15
@@ -1,6 +1,7 @@
|
||||
//! The Help menu (the ? button on the application bar): Documentation (the online book), About
|
||||
//! SolidCraft (version, commit, build date, licences, attributions, fonts), the keyboard shortcuts
|
||||
//! (read only), and Report an Issue, which opens a local editable report draft.
|
||||
//! The Help menu (the ? button on the application bar): About OneCAD (version, commit, build
|
||||
//! date, licences, attributions, fonts) and the keyboard shortcuts (read only). OneBSpace
|
||||
//! (OneCAD): upstream's Documentation link and GitHub issue reporting were removed —
|
||||
//! everything stays inside the workspace.
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
|
||||
@@ -10,15 +11,14 @@ use crate::theme::Tokens;
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const COMMIT: &str = env!("SOLIDCRAFT_COMMIT");
|
||||
pub const BUILD_DATE: &str = env!("SOLIDCRAFT_BUILD_DATE");
|
||||
/// The user and developer guide (docs/book, published by .github/workflows/pages.yml).
|
||||
pub const DOCS_URL: &str = "https://docs.solidcraft.io/";
|
||||
// OneBSpace (OneCAD): upstream's docs.solidcraft.io DOCS_URL was removed.
|
||||
const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md");
|
||||
use solidcraft_engine::licences::{LICENSE_APACHE, LICENSE_MIT, NOTICE, THIRD_PARTY};
|
||||
/// Fonts: what this build carries.
|
||||
pub const FONTS: &str = "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and \
|
||||
the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans \
|
||||
(SIL OFL 1.1). The Crafting Apps' optional font pack, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans \
|
||||
CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolidCraft builds.";
|
||||
CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in OneCAD builds.";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HelpState {
|
||||
@@ -32,7 +32,7 @@ pub struct HelpState {
|
||||
pub fn diagnostics(app: &SolveApp) -> String {
|
||||
let renderer = if app.viewport.gpu.is_some() { "GPU" } else { "CPU" };
|
||||
format!(
|
||||
"SolidCraft {VERSION} ({COMMIT}, built {BUILD_DATE})\nOS: {} {}\nRenderer: {renderer}{}\nWindow: {}\nDesign: {} ({} features, {} bodies)",
|
||||
"OneCAD {VERSION} ({COMMIT}, built {BUILD_DATE})\nOS: {} {}\nRenderer: {renderer}{}\nWindow: {}\nDesign: {} ({} features, {} bodies)",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
app.help.gpu.as_ref().map(|g| format!(", {g}")).unwrap_or_default(),
|
||||
@@ -43,11 +43,12 @@ pub fn diagnostics(app: &SolveApp) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Run a Help menu item: `docs`, `about`, `shortcuts`, `report`, `crashes`.
|
||||
/// Run a Help menu item: `about`, `shortcuts`, `report`, `crashes`.
|
||||
pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
|
||||
app.help.menu = false;
|
||||
match item {
|
||||
"docs" => ctx.open_url(egui::OpenUrl::new_tab(DOCS_URL)),
|
||||
// OneBSpace (OneCAD): upstream's `docs` item (docs.solidcraft.io) is gone. `report`
|
||||
// stays: the app-bar bug button opens the LOCAL draft only (no GitHub anywhere).
|
||||
"about" => app.help.about = true,
|
||||
"shortcuts" => {
|
||||
app.keymap.open = true;
|
||||
@@ -71,10 +72,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
|
||||
crate::frame::area(egui::Id::new("sc_help_menu")).fixed_pos(anchor).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
crate::context_menu::menu_frame(ui, |ui| {
|
||||
for (id, label) in [
|
||||
("docs", "Documentation"),
|
||||
("about", "About SolidCraft"),
|
||||
("about", "About OneCAD"),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("report", "Report an Issue…"),
|
||||
("crashes", "Crash Reports…"),
|
||||
] {
|
||||
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() {
|
||||
@@ -103,14 +102,14 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
crate::frame::window(ctx, crate::i18n::tr_label("About SolidCraft"), crate::frame::Width::Normal)
|
||||
crate::frame::window(ctx, crate::i18n::tr_label("About OneCAD"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_about"))
|
||||
.open(&mut open)
|
||||
.default_size(vec2(440.0, 560.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label(RichText::new("SolidCraft").size(22.0).strong());
|
||||
ui.label(RichText::new("OneCAD").size(22.0).strong());
|
||||
ui.label(format!("Version {VERSION} · commit {COMMIT} · built {BUILD_DATE}"));
|
||||
ui.label(RichText::new(crate::i18n::tr_label("Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.")).color(t.text_dim));
|
||||
ui.label(RichText::new(crate::i18n::tr_label("Parametric 3D CAD of the One B OS workspace, in Rust. Licensed MIT OR Apache-2.0.")).color(t.text_dim));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(crate::i18n::tr_label("Copy diagnostic information")).clicked() {
|
||||
|
||||
@@ -356,7 +356,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(36, 24))).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("SolidCraft").size(26.0).strong().color(t.text));
|
||||
// OneBSpace (OneCAD): our own name on the start page.
|
||||
ui.label(RichText::new("OneCAD").size(26.0).strong().color(t.text));
|
||||
ui.add_space(12.0);
|
||||
ui.label(RichText::new(crate::i18n::tr_label("Start")).size(16.0).color(t.text_dim));
|
||||
});
|
||||
@@ -406,9 +407,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
ui.add_space(24.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.hyperlink_to(app.tr("home.documentation"), crate::help::DOCS_URL);
|
||||
ui.hyperlink_to(app.tr("home.report_issue"), "https://github.com/bherbruck/solidcraft/issues/new");
|
||||
ui.hyperlink_to(app.tr("home.whats_new"), "https://github.com/bherbruck/solidcraft/releases/latest");
|
||||
// OneBSpace (OneCAD): no outbound links here (upstream had Documentation,
|
||||
// Report an Issue and What's New pointing at solidcraft.io / GitHub).
|
||||
if ui.link(app.tr("ui.keyboard_shortcuts")).clicked() {
|
||||
app.keymap.open = true;
|
||||
app.keymap.read_only = true;
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{SolveApp, dialogs, help};
|
||||
|
||||
const MAX_BODY: usize = 24_000;
|
||||
const MAX_IMAGE_PIXELS: usize = 16_000_000;
|
||||
const ISSUE_URL: &str = "https://github.com/bherbruck/solidcraft/issues/new";
|
||||
const ISSUE_URL: &str = "https://github.com/tangency-labs/solidcraft/issues/new";
|
||||
|
||||
thread_local! {
|
||||
static CAD_INPUT_BLOCKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
@@ -298,8 +298,8 @@ pub fn crash_draft(report: &serde_json::Value) -> Draft {
|
||||
let what = match report.get("kind").and_then(serde_json::Value::as_str) {
|
||||
Some("command_panic") => "An internal error stopped a command (the design was left unchanged)",
|
||||
Some("kernel_panic_recovered") => "The geometry kernel hit an internal error (caught; the operation failed)",
|
||||
Some("unclean_exit") => "SolidCraft closed unexpectedly (abort, stack overflow, a crash in a graphics driver or the process was killed)",
|
||||
_ => "SolidCraft hit an internal error",
|
||||
Some("unclean_exit") => "OneCAD closed unexpectedly (abort, stack overflow, a crash in a graphics driver or the process was killed)",
|
||||
_ => "OneCAD hit an internal error",
|
||||
};
|
||||
let command = report.get("command").and_then(|c| c.get("command")).and_then(serde_json::Value::as_str);
|
||||
let frames: Vec<&str> =
|
||||
@@ -336,7 +336,7 @@ pub fn crash_draft(report: &serde_json::Value) -> Draft {
|
||||
},
|
||||
s("id"),
|
||||
block(&format!(
|
||||
"SolidCraft {} ({}, built {})\nOS: {} {}\n{}",
|
||||
"OneCAD {} ({}, built {})\nOS: {} {}\n{}",
|
||||
field(app, "version"),
|
||||
field(app, "commit"),
|
||||
field(app, "built"),
|
||||
@@ -379,6 +379,8 @@ fn encode(value: &str) -> String {
|
||||
}
|
||||
|
||||
/// Long drafts stay local and can be copied into the browser editor; never discard them.
|
||||
/// OneBSpace (OneCAD): no longer called by the UI (reports stay local); kept for tests.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn draft_url(title: &str, body: &str) -> (String, bool) {
|
||||
let title = limited(title, 160);
|
||||
let base = format!("{ISSUE_URL}?title={}", encode(&title));
|
||||
@@ -492,7 +494,7 @@ pub(crate) fn receive(app: &mut SolveApp, token: u64, image: Arc<ColorImage>) {
|
||||
draft.note = "Screenshot dimensions are unsupported. The text draft is still available.".into();
|
||||
} else {
|
||||
draft.image = Some(image);
|
||||
draft.note = "Screenshot captured locally. Review it before attaching it on GitHub.".into();
|
||||
draft.note = "Screenshot captured locally. Review it before sharing it.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,7 +521,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// Only the editable report and evidence scroll. Keep the submission guide
|
||||
// and actions visible even when a screenshot or long report needs more room.
|
||||
egui::ScrollArea::vertical().id_salt("issue-report-review").max_height((screen.height() - 260.0).clamp(80.0, 520.0)).show(ui, |ui| {
|
||||
ui.label(crate::i18n::tr_label("Review the report and screenshot before opening GitHub."));
|
||||
ui.label(crate::i18n::tr_label("Review the report and screenshot before copying it out of the workspace."));
|
||||
// TextEdit rounds its allocation to physical pixels. Leaving one pixel of
|
||||
// room prevents that rounding from increasing the window width every frame
|
||||
// at fractional display/UI scales (for example 140%).
|
||||
@@ -560,7 +562,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
&& let Some(image) = draft.image.as_ref()
|
||||
{
|
||||
ctx.copy_image((**image).clone());
|
||||
draft.note = "Paste the image into the GitHub draft, or save the PNG if image copying is unavailable.".into();
|
||||
draft.note = "Paste the image into your own tracker, or save the PNG if image copying is unavailable.".into();
|
||||
}
|
||||
if ui.add_enabled(draft.image.is_some() && app.services.pick_save.is_some(), egui::Button::new("Save screenshot…")).clicked()
|
||||
&& let Some(image) = draft.image.clone()
|
||||
@@ -570,7 +572,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let result = crate::control::save_screenshot(&image, Some(&path));
|
||||
if let Some(draft) = app.issue_report.draft.as_mut() {
|
||||
draft.note = if result.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
"Screenshot saved. Attach the PNG in the GitHub draft.".into()
|
||||
"Screenshot saved. Attach the PNG wherever you share the report.".into()
|
||||
} else {
|
||||
format!("Could not save screenshot: {}", debug(&result.get("error"), 1000))
|
||||
};
|
||||
@@ -584,43 +586,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.label(RichText::new(crate::i18n::tr_label("Send your report on GitHub")).strong());
|
||||
let (url, copy_needed) = draft_url(&draft.title, &draft.body);
|
||||
ui.add(
|
||||
egui::Label::new(crate::i18n::tr_label(if copy_needed {
|
||||
"1. Open GitHub draft below, then paste the report copied to your clipboard."
|
||||
} else {
|
||||
"1. Open GitHub draft below. Your title and report will be filled in."
|
||||
}))
|
||||
.wrap(),
|
||||
);
|
||||
ui.add(egui::Label::new(crate::i18n::tr_label("2. Attach your screenshot: copy and paste it, or save and upload the PNG.")).wrap());
|
||||
ui.add(egui::Label::new(crate::i18n::tr_label("3. Click Submit new issue on GitHub to send your report.")).wrap());
|
||||
// OneBSpace (OneCAD): the report never leaves the workspace — no GitHub, no
|
||||
// external site. Copy the text (and the screenshot above) into your own tracker.
|
||||
ui.label(RichText::new(crate::i18n::tr_label("Your report stays in the workspace")).strong());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let t = crate::theme::Tokens::get();
|
||||
if crate::frame::add_outlined(
|
||||
ui,
|
||||
egui::Button::new(RichText::new(crate::i18n::tr_label("Open GitHub draft")).strong().color(t.text))
|
||||
.fill(t.accent_soft)
|
||||
.stroke(egui::Stroke::new(1.0, t.accent)),
|
||||
1.0,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
if copy_needed {
|
||||
ctx.copy_text(draft.body.clone());
|
||||
}
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
draft.note = if copy_needed {
|
||||
"Report copied. Paste into the GitHub draft, attach the screenshot, then click Submit new issue on GitHub."
|
||||
} else {
|
||||
"Review the GitHub draft and attach the screenshot. GitHub's Submit new issue button publishes it."
|
||||
}
|
||||
.into();
|
||||
}
|
||||
if ui.button(crate::i18n::tr_label("Copy report")).clicked() {
|
||||
ctx.copy_text(draft.body.clone());
|
||||
draft.note = "Report copied. Paste it into the GitHub draft if needed.".into();
|
||||
ctx.copy_text(format!("{}\n\n{}", draft.title, draft.body));
|
||||
draft.note = "Report copied to the clipboard — paste it into your own issue tracker.".into();
|
||||
}
|
||||
close = ui.button(crate::i18n::tr_label("Close draft")).clicked();
|
||||
});
|
||||
@@ -764,7 +736,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_long_draft_open_click_copies_the_full_reviewed_text() {
|
||||
fn issue_report_copy_click_copies_the_full_reviewed_text() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
let mut draft = draft_for(&h.app, None, None);
|
||||
@@ -772,9 +744,8 @@ mod tests {
|
||||
let reviewed = draft.body.clone();
|
||||
h.app.issue_report.draft = Some(draft);
|
||||
h.frames(4);
|
||||
assert!(h.widget("1. Open GitHub draft below, then paste the report copied to your clipboard.").is_some());
|
||||
assert!(h.widget("3. Click Submit new issue on GitHub to send your report.").is_some());
|
||||
let [x, y] = h.widget("Open GitHub draft").unwrap();
|
||||
assert!(h.widget("Your report stays in the workspace").is_some());
|
||||
let [x, y] = h.widget("Copy report").unwrap();
|
||||
assert_eq!(h.call("ui.move", json!({"x":x,"y":y}))["ok"], true);
|
||||
let (request, _reply) = crate::control::ControlRequest::new("ui.click", json!({"x":x,"y":y}));
|
||||
assert!(matches!(crate::control::handle(&mut h.app, &h.ctx, &request), crate::control::Outcome::Done(value) if value["ok"] == true));
|
||||
@@ -783,13 +754,11 @@ mod tests {
|
||||
h.frame();
|
||||
commands.extend(h.output.commands.clone());
|
||||
}
|
||||
assert!(commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text == &reviewed)), "commands: {commands:?}");
|
||||
assert!(
|
||||
commands.iter().any(|c| matches!(c, egui::OutputCommand::OpenUrl(url) if url.url.starts_with(ISSUE_URL) && !url.url.contains("&body=")))
|
||||
);
|
||||
assert!(commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text.contains(&reviewed))), "commands: {commands:?}");
|
||||
assert_eq!(h.app.issue_report.draft.as_ref().unwrap().body, reviewed);
|
||||
assert!(h.app.issue_report.draft.as_ref().unwrap().note.starts_with("Report copied. Paste into the GitHub draft"));
|
||||
assert!(h.app.issue_report.draft.as_ref().unwrap().note.contains("Submit new issue"));
|
||||
assert!(h.app.issue_report.draft.as_ref().unwrap().note.contains("clipboard"));
|
||||
// OneBSpace (OneCAD): nothing here opens an external site any more.
|
||||
assert!(!commands.iter().any(|c| matches!(c, egui::OutputCommand::OpenUrl(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -801,7 +770,7 @@ mod tests {
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":size[0],"height":size[1]}));
|
||||
let mut draft = draft_for(&h.app, None, None);
|
||||
draft.note = "Screenshot captured locally. Review it before attaching it on GitHub.".into();
|
||||
draft.note = "Screenshot captured locally. Review it before sharing it.".into();
|
||||
draft.image = Some(Arc::new(ColorImage::new([1600, 1000], vec![egui::Color32::GREEN; 1_600_000])));
|
||||
h.app.issue_report.draft = Some(draft);
|
||||
h.frames(20);
|
||||
@@ -812,7 +781,7 @@ mod tests {
|
||||
assert!((idle.width() - settled.width()).abs() < 1.0, "size={size:?}: {settled:?} grew to {idle:?}");
|
||||
assert!(idle.width() <= 790.0, "report must retain its width limit: {idle:?}");
|
||||
assert!(h.ctx.content_rect().contains_rect(idle), "size={size:?} scale={scale}: window must fit: {idle:?}");
|
||||
for label in ["Copy screenshot", "Save screenshot…", "Open GitHub draft", "Copy report", "Close draft"] {
|
||||
for label in ["Copy screenshot", "Save screenshot…", "Copy report", "Close draft"] {
|
||||
let [x, y] = h.widget(label).unwrap();
|
||||
assert!(idle.contains(egui::pos2(x as f32, y as f32)), "{label} must stay visible: {idle:?}, {x}, {y}");
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ fn sketch_tool_for(app: &SolveApp, key: &str) -> Option<&'static str> {
|
||||
pub fn bind(app: &mut SolveApp, id: &str, key: &str, replace: bool) -> Result<(), String> {
|
||||
let spec = solidcraft_engine::find_command(id).ok_or_else(|| format!("no command `{id}`"))?;
|
||||
if RESERVED.contains(&key) {
|
||||
return Err(format!("{key} is used by SolidCraft itself"));
|
||||
return Err(format!("{key} is used by OneCAD itself"));
|
||||
}
|
||||
if !key.is_empty() {
|
||||
let others: Vec<&'static str> = commands_for(app, key).into_iter().filter(|o| *o != id).collect();
|
||||
@@ -287,7 +287,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
Some((_, "reset_all")) => reset(app, None),
|
||||
Some((_, "export")) => {
|
||||
let text = crate::toolbar_custom::export(app).to_string();
|
||||
app.pick_save("SolidCraft shortcuts.json", &["json"], move |app, path| {
|
||||
app.pick_save("OneCAD shortcuts.json", &["json"], move |app, path| {
|
||||
if let Some(path) = path {
|
||||
app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}"));
|
||||
}
|
||||
|
||||
@@ -369,6 +369,11 @@ pub struct Prefs {
|
||||
pub agent_chat_side: String,
|
||||
pub agent_chat_width: f32,
|
||||
pub agent_chat_minimized: bool,
|
||||
/// Whether the chat is in its own window instead of the side panel, and that window's place
|
||||
/// and size as the system last reported them (`[x, y, width, height]`, points; `x` can be
|
||||
/// negative or past the main screen when the window sits on another one).
|
||||
pub agent_chat_popped: bool,
|
||||
pub agent_chat_window: Option<[f32; 4]>,
|
||||
/// Changes the agent may make for one message.
|
||||
pub agent_chat_turn_limit: u32,
|
||||
}
|
||||
@@ -460,6 +465,8 @@ impl Default for Prefs {
|
||||
agent_chat_side: "right".into(),
|
||||
agent_chat_width: 380.0,
|
||||
agent_chat_minimized: false,
|
||||
agent_chat_popped: false,
|
||||
agent_chat_window: None,
|
||||
agent_chat_turn_limit: crate::agent_chat::gate::DEFAULT_TURN_LIMIT,
|
||||
}
|
||||
}
|
||||
@@ -619,6 +626,8 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
p.agent_chat_side = d.agent_chat_side;
|
||||
p.agent_chat_width = d.agent_chat_width;
|
||||
p.agent_chat_minimized = d.agent_chat_minimized;
|
||||
p.agent_chat_popped = d.agent_chat_popped;
|
||||
p.agent_chat_window = d.agent_chat_window;
|
||||
p.agent_chat_turn_limit = d.agent_chat_turn_limit;
|
||||
}
|
||||
1 => {
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
.min_width(420.0)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label(crate::i18n::tr_label("SolidCraft closed before these designs were saved. Their last autosave can be opened."));
|
||||
ui.label(crate::i18n::tr_label("OneCAD closed before these designs were saved. Their last autosave can be opened."));
|
||||
ui.add_space(6.0);
|
||||
for e in app.recoverable.iter().take(20) {
|
||||
ui.label(RichText::new(&e.name).strong());
|
||||
|
||||
@@ -295,16 +295,10 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let left = r.left() + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
|
||||
// Keep the current header controls and product name when merging the new mark.
|
||||
let compact_actions = r.right() - left < 640.0;
|
||||
// Brand mark: the SolidCraft logo, drawn in code from the SVG's own coordinates
|
||||
// (viewBox 357 x 412). Same 20 px height and centre as the old block.
|
||||
{
|
||||
let c = pos2(left + 20.0, r.center().y);
|
||||
crate::icons::glyphs::brand_mark(ui.painter(), c);
|
||||
}
|
||||
if !compact_actions {
|
||||
ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolidCraft", FontId::proportional(14.0), t.app_bar_text);
|
||||
}
|
||||
let mut x = left + if compact_actions { 60.0 } else { 128.0 };
|
||||
// OneBSpace (OneCAD): the wordmark replaces the upstream mark; the name shows in the
|
||||
// compact layout too (a narrow bar loses the menus, not the app's own name).
|
||||
ui.painter().text(pos2(left + 20.0, r.center().y), Align2::LEFT_CENTER, "OneCAD", FontId::proportional(14.0), t.app_bar_text);
|
||||
let mut x = left + if compact_actions { 82.0 } else { 92.0 };
|
||||
// While a file loads the design waits: no menus or commands from the bar.
|
||||
let idle = !app.file_load.busy();
|
||||
// The File menu: a text button (New, Open, Save, Save As, Export…). Its handle keeps the
|
||||
@@ -412,7 +406,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if report_label {
|
||||
ui.painter().text(pos2(report.left() + REPORT_TEXT_X, report.center().y), Align2::LEFT_CENTER, report_text, report_font, t.app_bar_text);
|
||||
}
|
||||
if response.on_hover_text(crate::i18n::tr_label("Report a bug — review a screenshot and open a GitHub draft")).clicked() {
|
||||
if response.on_hover_text(crate::i18n::tr_label("Report a bug — capture a screenshot and diagnostics to copy")).clicked() {
|
||||
crate::help::run(app, &ui.ctx().clone(), "report");
|
||||
}
|
||||
// Preferences, at the top right like Fusion's profile menu (also Ctrl+,).
|
||||
|
||||
@@ -203,7 +203,7 @@ pub fn export(app: &SolveApp) -> Value {
|
||||
pub fn import(app: &mut SolveApp, v: &Value) -> Result<(), String> {
|
||||
// "solvecraft-shortcuts/1": files exported before the rename (compatibility shim).
|
||||
if !matches!(v.get("format").and_then(Value::as_str), Some("solidcraft-shortcuts/1" | "solvecraft-shortcuts/1")) {
|
||||
return Err("not a SolidCraft shortcuts file".into());
|
||||
return Err("not a OneCAD shortcuts file".into());
|
||||
}
|
||||
if let Some(s) = v.get("shortcuts") {
|
||||
app.keymap.load(s);
|
||||
|
||||
@@ -36,6 +36,12 @@ when you send a message or press **Connect**.
|
||||
window and let go. The side it would land on lights up while you drag. You can also pick
|
||||
**Left** or **Right** under Preferences → AI → Panel. SolidCraft remembers the side.
|
||||
- **Width:** drag the panel's inner edge. SolidCraft remembers it.
|
||||
- **Its own window:** the pop-out button in the header (a window with an arrow) moves the chat
|
||||
into a window of its own, named after the design, which you can move anywhere, including
|
||||
another screen. The window's own close button closes the chat, which opens there again next
|
||||
time; **Dock** (the same place in its header) puts it back in the side panel. You can also
|
||||
pick **Own window** under Preferences → AI → Panel. SolidCraft remembers where the window
|
||||
was and how big.
|
||||
- **Minimise** (the line in the header) folds the panel into a thin strip that keeps the chat
|
||||
icon, the agent's state, and a count of anything waiting for your answer. Click the icon to
|
||||
unfold it. **Close** (×) hides the panel. Neither stops the agent; **Stop** does.
|
||||
|
||||
@@ -24,7 +24,11 @@ spline and conic sides the slope is approximate: the moved outline is a curve of
|
||||
within 2 % of the taper's offset (or 10 µm) of the true one, a curve that turns too much for
|
||||
that being cut into pieces first; where a curve bends tighter than the offset, the offset folds
|
||||
and is smoothed over. A taper steeper than the profile's details (a stroke, a curve or a gap
|
||||
narrower than it) is refused with that reason; a smaller taper or distance builds.
|
||||
narrower than it) is refused with that reason; a smaller taper or distance builds. A taper
|
||||
composes with a start offset (a drafted pocket that starts part way up a part): the offset moves
|
||||
the profile along the normal and the sides are drafted from there, so the profile is the section
|
||||
at the start whatever the offset. A taper with a two-sided or symmetric extent is not built yet
|
||||
and says so.
|
||||
|
||||
**Thin Extrude** (`thin: true` on `solid.extrude`, the Thin Extrude box in its dialog) builds a
|
||||
wall of constant thickness along sketch curves instead of filling profiles: open chains (a single
|
||||
|
||||
@@ -156,7 +156,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `agent`, `modes`, `mode`, `models`, `model`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
|
||||
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
|
||||
| `ui.resize` | `width`, `height` | |
|
||||
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OneBSpace (OneCAD) — build the SolidCraft wasm bundle into the one_cad Odoo addon.
|
||||
|
||||
Stdlib-only build chain (python3 scripts/build_onecad_addon.py [--dry-run]):
|
||||
|
||||
1. optional clean: ODA_CLEAN=1 wipes the dist target before building
|
||||
2. build: prefer trunk (apps/solidcraft-web/Trunk.toml); fallback is
|
||||
onepixel's tools/cargo-run chain verbatim:
|
||||
cargo build --release --target wasm32-unknown-unknown -p solidcraft-web
|
||||
wasm-bindgen --target web --out-dir <dist> --out-name onecad <wasm>
|
||||
wasm-opt -Oz (when on PATH)
|
||||
plus index.html + static assets copied into <dist>
|
||||
3. rebrand: <title>OneCAD</title> + data-app-name="OneCAD" in index.html;
|
||||
drop sw.js / manifest.webmanifest / build.json / icon files
|
||||
4. verify gate: FAIL on absolute /(assets|fonts|blobs)/ references in any text
|
||||
file of the dist; require index.html, a *_bg.wasm and a
|
||||
wasm-bindgen glue .js — exits non-zero on failure
|
||||
5. mirror-copy: dist -> /home/z/my-project/one-os-dev/core-components/one_cad/static/onecad/
|
||||
(rsync-like: stale files in the destination are removed)
|
||||
6. reminder: bump bundleVersion (static/src/one_cad_app.js) + addon manifest version
|
||||
|
||||
Exit codes: 0 ok, 1 gate failure, 2 build environment problem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
APP = REPO / "apps" / "solidcraft-web"
|
||||
DIST = REPO / "dist" / "web"
|
||||
DEST = Path("/home/z/my-project/one-os-dev/core-components/one_cad/static/onecad")
|
||||
WASM_TARGET = "wasm32-unknown-unknown"
|
||||
WASM_ARTIFACT = REPO / "target" / WASM_TARGET / "release" / "solidcraft_web.wasm"
|
||||
OUT_NAME = "onecad"
|
||||
|
||||
TEXT_SUFFIXES = {".html", ".js", ".mjs", ".css", ".json", ".svg", ".txt", ".webmanifest", ".map"}
|
||||
ABS_REF = re.compile(r"""["'=(]/(?:assets|fonts|blobs)/""")
|
||||
ICON_FILE = re.compile(r"^(icon|apple-touch-icon|favicon).*", re.I)
|
||||
DROP_FILES = {"sw.js", "manifest.webmanifest", "site.webmanifest", "build.json"}
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[onecad] {message}")
|
||||
|
||||
|
||||
def sh(cmd: list[str], cwd: Path | None = None) -> None:
|
||||
log("$ " + " ".join(cmd) + (f" (cwd={cwd})" if cwd else ""))
|
||||
try:
|
||||
subprocess.run([str(c) for c in cmd], cwd=str(cwd) if cwd else None, check=True)
|
||||
except FileNotFoundError as exc:
|
||||
sys.exit(f"[onecad] tool not found: {exc.filename}")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
sys.exit(f"[onecad] command failed with exit code {exc.returncode}: {' '.join(str(c) for c in cmd)}")
|
||||
|
||||
|
||||
def clean_dist() -> None:
|
||||
if DIST.exists():
|
||||
log(f"ODA_CLEAN=1: wiping {DIST}")
|
||||
shutil.rmtree(DIST, ignore_errors=True)
|
||||
|
||||
|
||||
def build() -> Path:
|
||||
"""Build the wasm bundle; return the dist directory that holds index.html."""
|
||||
if shutil.which("trunk"):
|
||||
sh(["trunk", "build", "--release", "--public-url", "./"], cwd=APP)
|
||||
else:
|
||||
log("trunk not on PATH — using the cargo + wasm-bindgen chain (onepixel convention)")
|
||||
sh(["cargo", "build", "--release", "--target", WASM_TARGET, "-p", "solidcraft-web"], cwd=REPO)
|
||||
if not WASM_ARTIFACT.exists():
|
||||
sys.exit(f"[onecad] expected wasm artifact missing: {WASM_ARTIFACT}")
|
||||
DIST.mkdir(parents=True, exist_ok=True)
|
||||
sh(["wasm-bindgen", "--target", "web", "--out-dir", DIST, "--out-name", OUT_NAME, WASM_ARTIFACT])
|
||||
if shutil.which("wasm-opt"):
|
||||
optimized = DIST / f"{OUT_NAME}_bg.wasm.opt"
|
||||
sh(["wasm-opt", "-Oz", DIST / f"{OUT_NAME}_bg.wasm", "-o", optimized])
|
||||
os.replace(optimized, DIST / f"{OUT_NAME}_bg.wasm")
|
||||
else:
|
||||
log("wasm-opt not on PATH — skipping -Oz (larger bundle, still correct)")
|
||||
# Static assets trunk would have copied: index.html + the assets tree.
|
||||
if (APP / "index.html").exists():
|
||||
shutil.copy2(APP / "index.html", DIST / "index.html")
|
||||
if (APP / "assets").is_dir():
|
||||
shutil.copytree(APP / "assets", DIST / "assets", dirs_exist_ok=True)
|
||||
for candidate in (DIST, APP / "dist"):
|
||||
if (candidate / "index.html").is_file():
|
||||
return candidate
|
||||
sys.exit("[onecad] build produced no index.html in dist/web or apps/solidcraft-web/dist")
|
||||
|
||||
|
||||
def rebrand(dist: Path) -> None:
|
||||
index = dist / "index.html"
|
||||
html = index.read_text(encoding="utf-8")
|
||||
html, n_title = re.subn(r"<title\b[^>]*>.*?</title>", "<title>OneCAD</title>", html, flags=re.S | re.I)
|
||||
if 'data-app-name="' in html:
|
||||
html = re.sub(r'data-app-name="[^"]*"', 'data-app-name="OneCAD"', html)
|
||||
else:
|
||||
html = re.sub(r"<html\b", '<html data-app-name="OneCAD"', html, count=1)
|
||||
# Service worker, PWA manifest and icon links have no meaning inside the
|
||||
# Odoo iframe — drop the tags along with the files.
|
||||
html = re.sub(r"<link[^>]+rel=[\"']manifest[\"'][^>]*>\s*", "", html, flags=re.I)
|
||||
html = re.sub(r"<link[^>]+rel=[\"'][^\"']*(icon|apple-touch)[^\"']*[\"'][^>]*>\s*", "", html, flags=re.I)
|
||||
index.write_text(html, encoding="utf-8")
|
||||
log(f"rebranded {index} (title replacements: {n_title})")
|
||||
dropped = 0
|
||||
for path in sorted(dist.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name in DROP_FILES or ICON_FILE.match(path.name):
|
||||
path.unlink()
|
||||
dropped += 1
|
||||
log(f"dropped {dropped} sw/manifest/icon files from the bundle")
|
||||
|
||||
|
||||
def verify(dist: Path) -> list[str]:
|
||||
"""The gate: absolute /(assets|fonts|blobs)/ refs fail; glue + wasm must exist."""
|
||||
errors: list[str] = []
|
||||
if not (dist / "index.html").is_file():
|
||||
errors.append("missing index.html")
|
||||
wasm_files = [p for p in dist.rglob("*.wasm")]
|
||||
if not any(p.name.endswith("_bg.wasm") for p in wasm_files):
|
||||
errors.append("no *_bg.wasm (wasm-bindgen output) in dist")
|
||||
glue = [
|
||||
p
|
||||
for p in dist.rglob("*.js")
|
||||
if "wasm_bindgen" in p.read_text(encoding="utf-8", errors="ignore")
|
||||
or "wasm-bindgen" in p.read_text(encoding="utf-8", errors="ignore")
|
||||
]
|
||||
if not glue:
|
||||
errors.append("no wasm-bindgen glue .js in dist")
|
||||
offenders: list[str] = []
|
||||
for path in sorted(dist.rglob("*")):
|
||||
if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
if ABS_REF.search(text):
|
||||
offenders.append(str(path.relative_to(dist)))
|
||||
if offenders:
|
||||
errors.append(
|
||||
"absolute /(assets|fonts|blobs)/ references (must be relative — the bundle is served "
|
||||
f"from /one_cad/static/onecad/): {', '.join(offenders[:10])}"
|
||||
+ (f" … (+{len(offenders) - 10} more)" if len(offenders) > 10 else "")
|
||||
)
|
||||
if errors:
|
||||
for error in errors:
|
||||
log(f"GATE FAIL: {error}")
|
||||
else:
|
||||
log(f"GATE PASS: {len(wasm_files)} wasm, {len(glue)} glue js, all references relative")
|
||||
return errors
|
||||
|
||||
|
||||
def mirror(src: Path, dst: Path) -> None:
|
||||
"""rsync-like: make dst exactly src (extra files in dst are removed)."""
|
||||
src_files = {p.relative_to(src) for p in src.rglob("*") if p.is_file()}
|
||||
src_dirs = {p.relative_to(src) for p in src.rglob("*") if p.is_dir()}
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
removed = 0
|
||||
for path in sorted(dst.rglob("*"), key=lambda p: len(p.parts), reverse=True):
|
||||
rel = path.relative_to(dst)
|
||||
if path.is_dir() and rel not in src_dirs:
|
||||
shutil.rmtree(path)
|
||||
removed += 1
|
||||
elif path.is_file() and rel not in src_files:
|
||||
path.unlink()
|
||||
removed += 1
|
||||
for rel in sorted(src_dirs):
|
||||
(dst / rel).mkdir(parents=True, exist_ok=True)
|
||||
for rel in sorted(src_files):
|
||||
shutil.copy2(src / rel, dst / rel)
|
||||
log(f"mirrored {len(src_files)} files -> {dst} ({removed} stale entries removed)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--dry-run", action="store_true", help="print the plan, no builds/writes; gates run read-only against an existing dist")
|
||||
args = parser.parse_args()
|
||||
|
||||
tools = {name: bool(shutil.which(name)) for name in ("trunk", "cargo", "wasm-bindgen", "wasm-opt")}
|
||||
tool_report = ", ".join(f"{name}={'yes' if present else 'no'}" for name, present in tools.items())
|
||||
log(f"tools: {tool_report}")
|
||||
if not args.dry_run and not (tools["trunk"] or (tools["cargo"] and tools["wasm-bindgen"])):
|
||||
log("need trunk, or cargo + wasm-bindgen, on PATH")
|
||||
return 2
|
||||
|
||||
if args.dry_run:
|
||||
log(f"DRY RUN: clean={'yes' if os.environ.get('ODA_CLEAN') == '1' else 'no'} -> build ({'trunk' if tools['trunk'] else 'cargo chain'}) -> {DIST} -> rebrand -> gate -> mirror {DEST}")
|
||||
if (DIST / "index.html").is_file():
|
||||
log("an existing dist was found; running the verification gate read-only")
|
||||
return 1 if verify(DIST) else 0
|
||||
log("no dist yet — plan only")
|
||||
return 0
|
||||
|
||||
if os.environ.get("ODA_CLEAN") == "1":
|
||||
clean_dist()
|
||||
dist = build()
|
||||
rebrand(dist)
|
||||
if verify(dist):
|
||||
return 1
|
||||
mirror(dist, DEST)
|
||||
log("NEXT STEPS:")
|
||||
log(" 1. bump bundleVersion in one-os-dev/core-components/one_cad/static/src/one_cad_app.js (cache-busts the iframe bundle)")
|
||||
log(" 2. bump the version in one-os-dev/core-components/one_cad/__manifest__.py to match the addon release")
|
||||
log(" 3. upgrade the module (odoo -u one_cad) so the new static bundle is served")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -251,6 +251,79 @@ async function handleComment({ github, context, now = Date.now() }) {
|
||||
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 (or its title or body edited): it claims each open issue it
|
||||
// references that nobody holds yet, so a draft PR alone is enough (CONTRIBUTING.md).
|
||||
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 [];
|
||||
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 (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;
|
||||
}
|
||||
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 ${PR_IDLE_DAYS} days without pushes.`,
|
||||
});
|
||||
claimed.push(n);
|
||||
}
|
||||
return claimed;
|
||||
}
|
||||
|
||||
// The daily sweep over open issues labelled `in progress`.
|
||||
async function sweep({ github, context, now = Date.now() }) {
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
@@ -286,9 +359,11 @@ async function sweep({ github, context, now = Date.now() }) {
|
||||
module.exports = {
|
||||
LABEL,
|
||||
parseCommand,
|
||||
referencedIssues,
|
||||
isMaintainer,
|
||||
linkedPulls,
|
||||
claimStatus,
|
||||
handleComment,
|
||||
handlePull,
|
||||
sweep,
|
||||
};
|
||||
@@ -39,6 +39,11 @@ function fake(world = {}) {
|
||||
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 || [] }),
|
||||
},
|
||||
},
|
||||
@@ -231,3 +236,62 @@ test('sweep is idempotent: a released issue is no longer listed', async () => {
|
||||
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 = []) => ({
|
||||
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, ['in progress']);
|
||||
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'], ['in progress']) } });
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
// `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 carry their command, settings and
|
||||
// environment, 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 };
|
||||
}
|
||||
|
||||
// Sections only the app's own reports have.
|
||||
function fromApp(body) {
|
||||
return ['Settings and selection', 'Backtrace', 'Recent commands'].some((s) => section(body, s).trim());
|
||||
}
|
||||
|
||||
// Requests and plans, which aren't reproduced. Crash reports carry the crash folder's contents.
|
||||
const NOT_A_BUG = /^\s*\[(feat|feature|change|enhancement)|feature request|suggestion|\?\s*$|^\s*(tracker|refactor|support|add|allow|crash|how|why|request|missing)\b/i;
|
||||
// The app's report title. A long report opens the browser without its body and is copied to
|
||||
// the clipboard instead (crates/ui-egui/src/issue_report.rs, draft_url), so an empty one wasn't pasted.
|
||||
const APP_TITLE = /^[A-Z][A-Z ]+:\s*(operation failed|unexpected result)\b|^Modeling: unexpected result/;
|
||||
// A report this long describes what it saw; only short ones are asked.
|
||||
const SHORT = 300;
|
||||
const ATTACHMENT = /user-attachments\/|\.solidcraft\b|repro\.json|\.(step|stp|3mf|stl)\b/i;
|
||||
const COMMAND = /["`][a-z_]+\.[a-z_][a-z_.]*["`]/;
|
||||
const VERSION = /\b\d+\.\d+\.\d+\b|\b[0-9a-f]{7,40}\b/i;
|
||||
|
||||
// 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) || fromApp(b) || b.replace(/\s+/g, ' ').trim().length >= SHORT) return [];
|
||||
const steps = `${section(b, 'Reproduction')}\n${section(b, 'Steps to reproduce')}`;
|
||||
const reproducible =
|
||||
steps.replace(/\s+/g, '').length >= 30 || ATTACHMENT.test(b) || COMMAND.test(b) || /^\s*1[.)]\s/m.test(b);
|
||||
if (reproducible) return [];
|
||||
if (APP_TITLE.test(t) && !b.trim()) return ['paste'];
|
||||
const asks = ['steps'];
|
||||
if (!VERSION.test(`${t}\n${b}`)) asks.push('version');
|
||||
return asks;
|
||||
}
|
||||
|
||||
function askBody(asks) {
|
||||
if (asks.includes('paste')) {
|
||||
return [
|
||||
'Thanks for the report! It arrived empty: when a report is too long for the link, SolidCraft copies it to the clipboard instead.',
|
||||
'Please paste it here (or use **Report an Issue** again and paste before submitting).',
|
||||
'',
|
||||
`If we don't hear back within ${INFO_DAYS} days this is closed; reopen it any time.`,
|
||||
].join('\n');
|
||||
}
|
||||
const lines = ['Thanks for the report! To reproduce it we need a little more:'];
|
||||
lines.push(
|
||||
'- **Steps to reproduce**: what you clicked or typed, in order. For a problem on a particular design, ' +
|
||||
'`solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script; attach that, or the design.',
|
||||
);
|
||||
if (asks.includes('version')) lines.push('- **SolidCraft version** (Help → About) 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 };
|
||||
@@ -0,0 +1,148 @@
|
||||
'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: 'solidcraft' };
|
||||
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);
|
||||
|
||||
test('missing: short reports without steps are asked, with the version when it is absent', () => {
|
||||
assert.deepEqual(info.missing('Loft does not work', 'The Loft operation is not working.'), ['steps', 'version']);
|
||||
assert.deepEqual(info.missing('Loft does not work', 'Broken on 0.15.0.'), ['steps']);
|
||||
});
|
||||
|
||||
test('missing: empty app reports are asked to paste the copied report', () => {
|
||||
assert.deepEqual(info.missing('EXTRUDE: operation failed', ''), ['paste']);
|
||||
assert.deepEqual(info.missing('Modeling: unexpected result', ' '), ['paste']);
|
||||
});
|
||||
|
||||
test('missing: reproducible, long, app-filed and non-bug issues are left alone', () => {
|
||||
assert.deepEqual(info.missing('Loft fails', '## Reproduction\n1. sketch two circles\n2. loft them'), []);
|
||||
assert.deepEqual(info.missing('Loft fails', 'see design.solidcraft attached'), []);
|
||||
assert.deepEqual(info.missing('Loft fails', 'runs `solid.loft` and fails'), []);
|
||||
assert.deepEqual(info.missing('Loft fails', long), []);
|
||||
assert.deepEqual(info.missing('FILLET: operation failed', '## Settings and selection\n[["solid.fillet"]]'), []);
|
||||
assert.deepEqual(info.missing('Feature request: dark mode', 'please'), []);
|
||||
assert.deepEqual(info.missing('[FEAT] threads', 'please'), []);
|
||||
assert.deepEqual(info.missing('How do I draw a polygon?', ''), []);
|
||||
assert.deepEqual(info.missing('Crash: SolidCraft 0.15.0 ended without a clean exit', ''), []);
|
||||
});
|
||||
|
||||
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('Loft 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, /SolidCraft version/);
|
||||
assert.match(body, /14 days/);
|
||||
});
|
||||
|
||||
test('an empty app report is asked to paste, a maintainer\'s note is never asked', async () => {
|
||||
let w = fake();
|
||||
await info.handleOpened({ github: w.github, context: opened('EXTRUDE: operation failed', '') });
|
||||
assert.match(find(w.calls, 'comment')[1].body, /clipboard/);
|
||||
w = fake({ perms: { carol: 'write' } });
|
||||
assert.equal(await info.handleOpened({ github: w.github, context: opened('Loft broken', 'x', 'carol') }), false);
|
||||
assert.equal(w.calls.length, 0);
|
||||
});
|
||||
|
||||
const reply = (login, { comment = true, body = 'x', issueLabels = ['needs-info'] } = {}) => ({
|
||||
repo,
|
||||
payload: {
|
||||
issue: { number: 4, title: 'Loft 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. sketch a circle\n2. loft it to a point' });
|
||||
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: 'Loft broken', body: 'nope', labels: [], user: { login: 'a', type: 'User' } },
|
||||
{ number: 2, state: 'open', title: 'Loft 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 are you trying to do\nwork at night');
|
||||
ctx.payload.issue.labels = labels(['enhancement']);
|
||||
assert.equal(await info.handleOpened({ github, context: ctx }), false);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
// 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\/sketch\//, 'area:sketch'],
|
||||
[/^crates\/(kernel|geom)\//, 'area:kernel'],
|
||||
[/^crates\/doc\//, 'area:doc'],
|
||||
[/^crates\/io\//, 'area:io'],
|
||||
[/^crates\/(render|gpu)\//, 'area:render'],
|
||||
[/^(crates\/(ui-egui|space-mouse|threedx-client)|apps\/(solidcraft|solidcraft-web))\//, 'area:ui'],
|
||||
[/^(\.github\/|xtask\/|scripts\/|tools\/|docs\/|packaging\/|[^/]+\.md$)/, 'area:infra'],
|
||||
];
|
||||
|
||||
// Command id prefix (the in-app reports quote it) → area.
|
||||
const COMMAND_AREAS = {
|
||||
sketch: 'area:sketch',
|
||||
solid: 'area:kernel',
|
||||
surface: 'area:kernel',
|
||||
sheet: 'area:kernel',
|
||||
plastic: 'area:kernel',
|
||||
construct: 'area:kernel',
|
||||
body: 'area:kernel',
|
||||
component: 'area:doc',
|
||||
joint: 'area:doc',
|
||||
parameters: 'area:doc',
|
||||
timeline: 'area:doc',
|
||||
occurrence: 'area:doc',
|
||||
motion: 'area:doc',
|
||||
contact: 'area:doc',
|
||||
explode: 'area:doc',
|
||||
file: 'area:io',
|
||||
render: 'area:render',
|
||||
appearance: 'area:render',
|
||||
view: 'area:ui',
|
||||
browser: 'area:ui',
|
||||
selection: 'area:ui',
|
||||
};
|
||||
|
||||
// Words in an issue's title → area. Conservative: a miss leaves the issue for a person.
|
||||
const WORD_AREAS = [
|
||||
[/\b(sketch|constraint|dimension|solver|spline)/i, 'area:sketch'],
|
||||
[/\b(fillet|chamfer|boolean|shell|loft|sweep|revolve|extrude|thread|hole|b-?rep|combine|offset|mirror|kernel|pattern)(s|ed|ing)?\b/i, 'area:kernel'],
|
||||
[/\b(step|iges|3mf|stl|gltf|dxf|obj|import|export)\b/i, 'area:io'],
|
||||
[/\b(render|gpu|wgpu|shader|opengl|vulkan|metal|appearance)\b/i, 'area:render'],
|
||||
[/\b(joint|assembly|timeline|parameter|component)/i, 'area:doc'],
|
||||
[/\b(toolbar|dialog|viewport|panel|button|shortcut|menu|window|space ?mouse|3dconnexion|spacenavd)/i, 'area:ui'],
|
||||
];
|
||||
|
||||
const CRASH = /^\s*crash\b|\bpanic(ked)?\b|\bcaught panic\b/i;
|
||||
const FEATURE = /^\s*\[?feature( request)?\]?\b/i;
|
||||
// The in-app reports: `<COMMAND>: operation failed` / `Modeling: unexpected result`.
|
||||
const IN_APP_FAILURE = /:\s*(operation failed|unexpected result)\b/i;
|
||||
|
||||
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 hasPriority(names) {
|
||||
return names.some((n) => n.startsWith('priority:'));
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
labels.add('bug');
|
||||
labels.add('priority:P1'); // a crash loses work (CLAUDE.md, "Never crash")
|
||||
} else if (IN_APP_FAILURE.test(t) || /^##\s*(behavior|actual behavior)\b/im.test(b)) {
|
||||
labels.add('bug');
|
||||
} else if (FEATURE.test(t)) {
|
||||
labels.add('enhancement');
|
||||
}
|
||||
// The title names the feature; the command quoted in the report's own section is the fallback
|
||||
// (a report's history quotes earlier commands too, so the rest of the body is not read).
|
||||
let area = null;
|
||||
for (const [re, a] of WORD_AREAS) {
|
||||
if (re.test(t)) {
|
||||
area = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!area) {
|
||||
const own = `${t}\n${section(b, 'Settings and selection')}\n${section(b, 'Command')}`;
|
||||
const m = /["`]([a-z_]+)\.[a-z_][a-z_.]*["`]/.exec(own);
|
||||
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) && !wanted.some((l) => l.startsWith('priority:'))) 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 || !label.name.startsWith('priority:')) 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:kernel P1 bug -enhancement`: tokens name existing labels; `P1` is short for
|
||||
// `priority:P1` and replaces any other priority; a leading `-` removes.
|
||||
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;
|
||||
if (/^p[0-3]$/i.test(name)) name = 'priority:' + name.toUpperCase();
|
||||
if (name) (minus ? remove : add).push(name);
|
||||
}
|
||||
return { add, remove };
|
||||
}
|
||||
|
||||
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 Set(
|
||||
(await github.paginate(github.rest.issues.listLabelsForRepo, { ...repoArgs(context), per_page: 100 })).map((l) => l.name),
|
||||
);
|
||||
const unknown = [...cmd.add, ...cmd.remove].filter((n) => !known.has(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 current = labelNames(issue);
|
||||
const remove = new Set(cmd.remove);
|
||||
if (cmd.add.some((n) => n.startsWith('priority:'))) {
|
||||
for (const n of current) if (n.startsWith('priority:') && !cmd.add.includes(n)) remove.add(n);
|
||||
}
|
||||
if (cmd.add.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 = cmd.add.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,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const triage = require('./triage.js');
|
||||
|
||||
const repo = { owner: 'tangency-labs', repo: 'solidcraft' };
|
||||
|
||||
// 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:kernel', 'priority:P1', 'priority:P2', '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 failure reports get bug and the area from the title', () => {
|
||||
assert.deepEqual(triage.classifyIssue('FILLET: operation failed', '## Actual behavior\nx'), ['bug', 'area:kernel']);
|
||||
assert.deepEqual(triage.classifyIssue('CREATE SKETCH: operation failed', ''), ['bug', 'area:sketch']);
|
||||
});
|
||||
|
||||
test('classifyIssue: the report\'s own command is the fallback, not its history', () => {
|
||||
const body = '## Recent commands\n"sketch.line"\n## Settings and selection\n [["file.export", {}]]\n## Environment\n';
|
||||
assert.deepEqual(triage.classifyIssue('Modeling: unexpected result', body), ['bug', 'area:io']);
|
||||
assert.deepEqual(triage.classifyIssue('Modeling: unexpected result', '## Recent commands\n"sketch.line"\n'), ['bug']);
|
||||
});
|
||||
|
||||
test('classifyIssue: crashes are P1 bugs, the command named in the title gives the area', () => {
|
||||
assert.deepEqual(triage.classifyIssue('Crash: ended without a clean exit while running `solid.loft`', ''), [
|
||||
'bug',
|
||||
'priority:P1',
|
||||
'area:kernel',
|
||||
]);
|
||||
assert.deepEqual(triage.classifyIssue('Joining 90 segments: caught panic at truck', ''), ['bug', 'priority:P1']);
|
||||
});
|
||||
|
||||
test('classifyIssue: feature requests and unmatched text', () => {
|
||||
assert.deepEqual(triage.classifyIssue('Feature request: macOS please make use of menubar', ''), ['enhancement', 'area:ui']);
|
||||
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/kernel/src/a.rs', 'crates/geom/b.rs', 'crates/ui-egui/c.rs', 'README.md', 'Cargo.lock']),
|
||||
['area:infra', 'area:kernel', 'area:ui'],
|
||||
);
|
||||
assert.deepEqual(triage.classifyPaths(['crates/engine/src/cmd/x.rs']), []);
|
||||
});
|
||||
|
||||
test('parseTriage', () => {
|
||||
assert.deepEqual(triage.parseTriage('/triage area:kernel p1 -enhancement'), {
|
||||
add: ['area:kernel', 'priority:P1'],
|
||||
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: 'SHELL: operation failed', body: '', labels: [] };
|
||||
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), [
|
||||
'bug',
|
||||
'area:kernel',
|
||||
'needs-triage',
|
||||
]);
|
||||
assert.ok(find(calls, 'createLabel'));
|
||||
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['bug', 'area:kernel', '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: 'Crash: x', body: '', labels: labels(['bug']) };
|
||||
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), ['priority:P1']);
|
||||
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['priority:P1']);
|
||||
});
|
||||
|
||||
test('an opened PR is labelled from its paths and never gets needs-triage', async () => {
|
||||
const { github, calls } = fake({ files: ['crates/sketch/src/solve.rs'] });
|
||||
const pull_request = { number: 9, state: 'open', title: 'Sketch: thing', body: '', labels: [] };
|
||||
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { pull_request } } }), ['area:sketch']);
|
||||
assert.deepEqual(find(calls, 'addLabels')[1], { ...repo, issue_number: 9, labels: ['area:sketch'] });
|
||||
});
|
||||
|
||||
test('a priority label clears needs-triage', async () => {
|
||||
const { github, calls } = fake();
|
||||
const issue = { number: 5, labels: labels(['needs-triage', 'priority:P2']) };
|
||||
assert.equal(await triage.handleLabeled({ github, context: { repo, payload: { issue, label: { name: 'priority:P2' } } } }), 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 priority and clears needs-triage', async () => {
|
||||
const { github, calls } = fake({ perms: { carol: 'write' } });
|
||||
const ctx = comment('/triage area:kernel P1', 'carol', ['priority:P2', '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', 'priority:P2']);
|
||||
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['area:kernel', 'priority:P1']);
|
||||
assert.equal(find(calls, 'react')[1].content, '+1');
|
||||
});
|
||||
|
||||
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: 'EXTRUDE: operation failed', 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:kernel', '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]);
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
"10": {"area":2809.248846,"bodies":1,"edges":39,"error":null,"faces":16,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut box","fillet","hole"],"unsupported":0,"volume":6696.329905},
|
||||
"11": {"area":7858.300481,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","hole","rectangular pattern","fillet"],"unsupported":0,"volume":34605.048259},
|
||||
"12": {"area":4668.664174,"bodies":2,"edges":59,"error":null,"faces":25,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","chamfer","join cylinder","join box","cut cylinder","circular pattern"],"unsupported":3,"volume":11874.471611},
|
||||
"13": {"area":2101.521015,"bodies":1,"edges":103,"error":null,"faces":41,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","join box","rectangular pattern","cut cylinder","cut box","fillet","hole"],"unsupported":0,"volume":2396.939546},
|
||||
"13": {"area":2112.846954,"bodies":1,"edges":99,"error":null,"faces":39,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","join box","rectangular pattern","cut cylinder","cut box","fillet","hole"],"unsupported":0,"volume":2410.783741},
|
||||
"14": {"area":10553.267409,"bodies":1,"edges":43,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","fillet"],"unsupported":0,"volume":10369.008764},
|
||||
"15": {"area":2312.730168,"bodies":1,"edges":39,"error":null,"faces":15,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","cut box","rectangular pattern","cut sphere","cut cylinder","cut sphere","join cylinder"],"unsupported":0,"volume":5728.661237},
|
||||
"16": {"area":8872.873884,"bodies":3,"edges":49,"error":null,"faces":23,"outcome":"ok","rejected":0,"shells":3,"steps":["box","join cylinder","circular pattern","hole"],"unsupported":0,"volume":44953.403148},
|
||||
@@ -68,7 +68,7 @@
|
||||
"64": {"area":4088.335645,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","fillet","join cylinder"],"unsupported":0,"volume":8495.220295},
|
||||
"65": {"area":8436.255548,"bodies":1,"edges":37,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut cylinder","fillet","rectangular pattern","circular pattern","circular pattern","fillet","cut box"],"unsupported":0,"volume":45143.475817},
|
||||
"66": {"area":9222.76638,"bodies":1,"edges":99,"error":null,"faces":37,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","join box","join box","join box","cut sphere","cut box"],"unsupported":1,"volume":35217.203139},
|
||||
"67": {"area":7943.46222,"bodies":2,"edges":88,"error":null,"faces":37,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","cut cylinder","hole","join cylinder","join box","hole","chamfer","rectangular pattern"],"unsupported":2,"volume":27607.962186},
|
||||
"67": {"area":7943.462219,"bodies":2,"edges":86,"error":null,"faces":36,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","cut cylinder","hole","join cylinder","join box","hole","chamfer","rectangular pattern"],"unsupported":2,"volume":27607.962204},
|
||||
"68": {"area":7338.675126,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","hole","hole","rectangular pattern","rectangular pattern","fillet","circular pattern"],"unsupported":0,"volume":29780.741772},
|
||||
"69": {"area":6451.122767,"bodies":1,"edges":60,"error":"offset: a corner whose faces don't meet the same way after t","faces":27,"outcome":"failed","rejected":0,"shells":3,"steps":["revolved section","join box","shell","shell"],"unsupported":0,"volume":4761.477515},
|
||||
"70": {"area":3682.669157,"bodies":1,"edges":24,"error":"lumps: shell boundaries intersect or touch; cavity ownership","faces":12,"outcome":"failed","rejected":0,"shells":2,"steps":["extruded profile","join box","hole"],"unsupported":1,"volume":9155.027162},
|
||||
@@ -95,7 +95,7 @@
|
||||
"91": {"area":3958.046518,"bodies":1,"edges":50,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","fillet"],"unsupported":0,"volume":9721.244548},
|
||||
"92": {"area":2855.139217,"bodies":1,"edges":30,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer","cut sphere"],"unsupported":0,"volume":9814.30292},
|
||||
"93": {"area":7266.404017,"bodies":7,"edges":111,"error":null,"faces":51,"outcome":"ok","rejected":0,"shells":7,"steps":["extruded profile","fillet","chamfer","join box","circular pattern","rectangular pattern","cut cylinder"],"unsupported":0,"volume":12734.393171},
|
||||
"94": {"area":3480.70333,"bodies":1,"edges":61,"error":null,"faces":26,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856},
|
||||
"94": {"area":3480.70333,"bodies":1,"edges":63,"error":null,"faces":27,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856},
|
||||
"95": {"area":2375.340299,"bodies":1,"edges":33,"error":null,"faces":12,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","hole","cut cylinder","cut sphere","circular pattern","cut sphere"],"unsupported":1,"volume":6704.220611}
|
||||
},
|
||||
"platforms": {"windows":{"seeds":{"1":{"area":2642.450837,"bodies":1,"edges":32,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut cylinder","join cylinder"],"unsupported":0,"volume":6991.569339},"23":{"area":1795.696636,"bodies":1,"edges":15,"error":null,"faces":7,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere"],"unsupported":0,"volume":3198.694618},"34":{"area":1086.303608,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.3653},"67":{"area":7943.46222,"bodies":2,"edges":86,"error":null,"faces":36,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","cut cylinder","hole","join cylinder","join box","hole","chamfer","rectangular pattern"],"unsupported":2,"volume":27607.962186},"94":{"area":3470.837042,"bodies":1,"edges":63,"error":null,"faces":27,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.438772}}}},
|
||||
@@ -137,7 +137,8 @@
|
||||
{"reason":"#1670 and #1753 (mesh export: twisted ruled faces divided to the chord tolerance, and the faces of an edge sharing its points exactly) change the meshes the kernel's validity and volume checks measure: seed 55's body (a filleted box cut by spheres and patterned) now passes the validity check it failed, and its volume moves by 1.3e-5 relative. Pull requests ran only every 8th seed, so main's full run found it. Recorded from main's CI output (Linux).","seeds":[55]},
|
||||
{"reason":"#853 recorded seeds 13, 71 and 94 as its own branch built them (13: 40 faces/101 edges, 71: 19/50, 94: 27/63), but with main's kernel they build as before (41/103, 20/52, 26/61), so every full run on main failed on them after #853 landed. This puts back the counts main builds; #853's conical-cut refinement (seed 20) is unchanged. Steps, outcomes, volumes and areas are the same.","seeds":[13,71,94]},
|
||||
{"reason":"#1853 (a part revolve of a profile with an edge on the axis is swept a third of a turn at most per face, so the boolean can intersect its faces): seeds 28 and 95 start with a rectangle on the axis turned half a turn, now two quarter turns. Seed 28 (joined by a cylinder) has more faces for the same shape (5902.1214 -> 5902.1117 mm3, the new faces' meshes). Seed 95's hole into it failed with 'boolean Cut: no result' on the half-turn faces and now builds, so the generator's later picks move. Values from Linux CI.","seeds":[28,95]},
|
||||
{"reason":"Independently reproduced on unmodified main 1e984c8e on Windows; the extrusion branch produces the same values. Shared references and tolerances remain unchanged.","platform":"windows","seeds":[1,23,34,67]},
|
||||
{"reason":"Seed 94 independently reproduced on unmodified main c5992386 on Windows after the extrusion rebase: the same steps, outcome and geometry values as the rebased branch. Shared references and tolerances remain unchanged.","platform":"windows","seeds":[94]}
|
||||
{"platform":"windows","reason":"Independently reproduced on unmodified main 1e984c8e on Windows; the extrusion branch produces the same values. Shared references and tolerances remain unchanged.","seeds":[1,23,34,67]},
|
||||
{"platform":"windows","reason":"Seed 94 independently reproduced on unmodified main c5992386 on Windows after the extrusion rebase: the same steps, outcome and geometry values as the rebased branch. Shared references and tolerances remain unchanged.","seeds":[94]},
|
||||
{"reason":"#2064 (multi-profile extrusion) merges coplanar faces the boolean used to leave split. Seed 67 has one face/two edges fewer from its first cut on, at the same volume at every step; seed 94's last box cut leaves one face/two edges more, same volume and area. Seed 13's second cylinder cut leaves 35 faces/84 edges instead of 37/88 at the same volume, so its random fillet (edges picked by index) rounds different edges: 2688.99 instead of 2675.18 mm3 after the fillet; the hole after it removes the same 278.2 mm3. Every step passes the validity and volume-direction checks. Pull request CI ran only every 8th seed, so main was the first run to see these.","seeds":[13,67,94]}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user