Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 });
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -866,6 +866,7 @@
|
||||
"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",
|
||||
@@ -887,10 +888,12 @@
|
||||
"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_queue": "Queue",
|
||||
|
||||
@@ -866,6 +866,7 @@
|
||||
"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": "編集",
|
||||
@@ -887,10 +888,12 @@
|
||||
"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_queue": "待機に追加",
|
||||
|
||||
@@ -866,6 +866,7 @@
|
||||
"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": "编辑",
|
||||
@@ -887,10 +888,12 @@
|
||||
"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_queue": "加入队列",
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -407,7 +407,7 @@ 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.report_issue"), "https://github.com/tangency-labs/solidcraft/issues/new");
|
||||
ui.hyperlink_to(app.tr("home.whats_new"), "https://github.com/bherbruck/solidcraft/releases/latest");
|
||||
if ui.link(app.tr("ui.keyboard_shortcuts")).clicked() {
|
||||
app.keymap.open = 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) };
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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