Compare commits

...

40 Commits

Author SHA1 Message Date
bherbruck a2eb31816c ops: offset surfaces, each face on its exact offset, joined where they meet (#3138)
SolidCraft's Surface > Offset offsets each face as a sheet and assembles the
records in the app before stitching (surface_offset.rs, surfaces::offset_faces).
offset_surface copies each picked face out on its own (copy_faces_from, its
attributes with it), offsets it with offset_sheet, copies the sheets into one
body and stitches them at the caller's tolerance: neighbours meeting smoothly
keep their shared edge, across a sharp edge they come apart, and faces that
close make a solid. An offset that would fold is the kernel's refusal.
history::recorded_into gives the new body's history against its source, so
each face is the OffsetOf the face it came from.

Tests (exact measures): a cube's top 5 up is area 100 at z = 15, the top's
offset; two faces across a sharp edge come apart (2 faces, 8 edges); a zero
distance copies (area 200); a cylinder side r 10 out 2 is area 2π·12·20 with its
seam kept, and refused at -10.5; a ball r 10 out 2 closes into the ball of
radius 12 (area 4π·144, volume 4π·1728/3), refused at -10.

Fixes #3138.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:50:17 -05:00
bherbruck d88a7b1e0d ops: the face at a point, and a pick grown across the edges where one surface continues (#3138)
SolidCraft picks the face under a point by the nearest display-mesh triangle
(offset_backend.rs faces_under) and grows a pick to the faces on the same
surface by comparing the record's numbers with its own tolerances
(offset.rs with_same_surface). Body::face_at classifies the point with the
kernel (Point3DClassify at the caller's reach): the face it lies on, and none
on an edge or a vertex, off the body, or unclassified. Body::faces_on_one_surface
grows the picks across every edge not kept where query::faces_continue proves
the two faces continue one surface definition with the same outward side; an
undecided edge is not crossed.

Tests: a cube halved by x = 5: a point on each top half picks that half, and
none on the split line, a box edge, outside or inside; one top half grows to
the other across the split line, and stays alone with that line kept.

Part of #3138.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:48:13 -05:00
bherbruck 3fffb8a9b9 offset: the executive marks how it made each face; faces_continue; ops history (#3138)
SolidCraft decides whether a shell's face is a face or its offset copy by mesh
distance (offset_backend.rs with_offsets), because the tag a copy carries is its
source's. The executive already relates each face it makes to what made it.
mark_offset_roles, called first in piecewise_merge where those relations are
final, marks each such face with ids::OFFSET_ROLE: an offset copy, a face along
an edge (with the closing faces, a sheet's side walls), a face at a vertex.
It adds no relation and changes no decision.

query::faces_continue proves two faces across an edge lie on one surface with
the same outward side only on identical definitions, bit for bit, and is
Undecided otherwise; it never samples. solidcore-ops' history::recorded tags the
operand's faces and edges by record index, runs the call, and reads each result
face's origin and change and each edge's operand edges, in record order. The
tags and roles are removed before it returns.

offset.offset_executive_p2: 30 rows, the attributes block only (the role now on
the offset faces). A/B: 30 with the pass, 0 without. Tests: a shelled box
(424), a thickened sheet (100), a moved face (1200), and #865's plate, whose
crossing-loops-rebuilt top is the top's offset.

Part of #3138.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:24:19 -05:00
Tianyi 201de37b96 File menu: look up the labels that skipped the translation catalog (#14694)
* File menu: look up the labels that skipped the translation catalog

* Add the French labels for the File menu export items
2026-11-10 19:04:25 +00:00
bherbruck 0cb554058c Service worker: only a first worker records the pages it claims as network pages (#14699)
After an update, a page the new worker claimed before the old worker's record of it was written was recorded as a network page, so its later files came from the network, which serves the newer build (the open v3 page was given v4's shared.js in a queue run). A new worker now records claimed pages as network pages only when no earlier build is kept (a first install, when open pages did come from the network); after an update a page with no record gets the visible failure. The offline test now prints the page records when that check fails.

Changes a default: no

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:03:34 +00:00
SS-EN c481b24329 Assemblies: Capture Position also saves joint values, and Revert Position puts them back (#14315)
* Joints: store a rest position and reset one or all joints to it (#14275)

Each joint can store its current values as a rest position (joint.set_rest)
and go back to it (joint.reset), one joint or every joint at once. The
joint's right-click menu has Set Current Pose as Rest and Reset to Rest;
the browser's Joints folder menu sets or resets every joint. joint.list
reports rest. The rest values save with the design (a pending format
change); a joint without one, as in every older file, loads unchanged and
a reset leaves it where it is, listed in skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Joints: rest-position labels in every complete catalog and Korean (#14275)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Joints: captured-value helpers before the test module (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:54:59 +00:00
Brett Lawson 0bad690ed4 solidcore: mass, heal and geom failures carry a typed reason instead of bare statuses (part of #13790) (#14605)
* solidcore: mass, heal and geom failures carry a typed reason instead of bare statuses (part of #13790)

mass (integrator, properties, enclosure), heal (gap, face_props, heal_data,
stitch) and geom (vectors, matrix, transform, boxes, geom_utility, local_solve1d,
global_solver, grid, tol, attribute, a_object, tools, periodic_extent1d) return a
MassFailure, HealFailure or GeomFailure under the new Reason::Mass, Reason::Heal
and Reason::Geom instead of a bare Upstream status, each with a phrase or a
variant saying what failed. The operation path, entity (none), comparison status
and kind are those of the Upstream it replaces, so sm_status(), kind(),
is_port_failure() and is_status_answer() answer as before; no cause is kept.

The integrator's step-control, integrand and skipped-span failures are their own
MassFailure variants, which the integrator and typed_failure match instead of the
operation path; heal's not-yet-healable face is HealFailure::NotYetHealable, which
the heal sequence matches. geom's FALSE (coincident circle points), SM_ERR_WARNING
(nearly singular centre system) and SM_ERR_INVALID_INPUT (tolerance below
SM_EFF_ZERO) keep their statuses as GeomFailure variants. The stitch entry points'
"SmApiStitch..." messages are worded in solidcore terms.

upstream_status() answers for the new reasons as for an Upstream. The goldens
runners that record these statuses (the mass, heal and geom runners, and
blend.fillet_geom_p1, curve composite_curve_p1, curve_p5, curve_p9, hyperbola,
parabola, geom.local_solve_nd, surface volume_p1/p2, which reach geometry
utilities) read it, so every row stays as it was. The fuzz corpus keeps the grid's
voxel refusal under its new message. SolidCraft's status() passes the failures on
as their status, as it did the Upstream.

Held for open PRs: geom extent1d/extent2d (#13605), expansion (#13965),
local_solve_nd (#14102). The singular system of Matrix::solve_linear_system (and
solve_with_lu, which it calls) stays a bare status while local_solve_nd matches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

* solidcore: the bare-status ratchets cut the test module on CRLF checkouts too

The ratchets find each file's `#[cfg(test)]` module by an LF-only string, so on a
Windows checkout with core.autocrlf=true it never matched and the `sm_err` calls
in test modules were counted (reported on #14605). Normalise line endings after
reading, in all six ratchets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:46:12 +00:00
austin62jake 4c512a243f solidcore: retire five topology UpstreamFixes flags (#14246, batch A16) (#14679)
* solidcore: retire five topology UpstreamFixes flags (#14246, batch A16)

The corrected path becomes the only path, and the flag and the old path are
deleted, for:
- loop_orientation_floor_from_loop_box (WR-561): a loop's orientation floor
  is relative to its own bounding box;
- trim_boundary_seam_wrap (WR-491): a UV gap of whole periods between
  consecutive trim curves is a seam crossing;
- imprint_extension_keeps_analytic_curve (WR-601): a continued line, circle
  or ellipse stays that kind;
- shell_inner_shell_of_statuses (WR-497): IsInnerShellOf passes on the
  statuses upstream ignores, and the Checked::Ignored arms go;
- is_iso_curve_at_propagates_canonical_failure (WR-97): a failing
  GetCanonical is an error. is_iso_curve_at propagates it itself, and
  is_iso_curve_at_with_fixes is removed.

SolidCraft's preset (crates/smlib/src/core.rs) and the kernel rig's
fixes.json had all five on; their lines go, with the goldens' enable_fix arm
for imprint_extension_keeps_analytic_curve. Tests that compared the flag off
with on keep the corrected half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* plan: the five A16 flags' records note that the flags are removed (#14246)

One addendum each for WR-561, WR-491, WR-601, WR-497 and WR-97: the
corrected path is the only path, what was removed, the tests, and the
goldens (no unit changes on Windows against main 87400c80e).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:42:54 +00:00
rugbymauri 2b88913228 Extrude: a sketch outline whose arc meets lines tangentially through point-on-curve constraints is one closed profile (#14256) (#14656)
* claim: #14256

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Sketch profiles: an outline whose arc meets lines tangentially through point-on-curve constraints closes (#14256)

Where a line meets an arc tangentially and the joint is held by two
point-on-curve constraints instead of one shared point, the solver fixes
the arc's end along the tangent only to about sqrt(2 * radius * 1e-9 mm).
On a 25 mm arc that leaves the ends up to 2.25e-4 mm apart, past the
1e-4 mm the profile search joins ends at, so the outline had no closed
profile and Extrude could not pick it.

The profile search now also joins the ends of a line and an arc, or of
two arcs, up to 1e-3 mm apart when each end lies within 1e-7 mm of the
other curve's line or circle. Two lines, and two arcs of one circle,
never join this way, so ends that are genuinely apart stay apart.

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-10 18:30:18 +00:00
0rdnungsamt 0e5e689f7c Assistant model box: the search hint says any model id can be typed (part of #14369) (#14466)
* claim: model box hint that any model id can be typed (#14369)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Model box: the search hint says any model id can be typed (#14369)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Model box hint: Arabic, Hebrew, Japanese, Korean, Vietnamese and Chinese

The complete catalogs need every English message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:12:42 +00:00
ABDULLAH ed7c33df66 Arabic: denoising terms, excluded-pixel labels, caustics names, texture filtering help; construction line, agent pick and control port notes updated to the new English; tanween, mouse, sketch and Fillet terms in new strings (#14252)
* Arabic: denoising terms, excluded-pixel labels, caustics names, texture filtering and metal reflectance help

* Arabic: construction line typed distance, agent pick pill, control port note, tanween, mouse and sketch terms in new strings

Re-translate five messages whose English changed (construction line help, agent chat pick tip, control channel note) and fix new pending strings: tanween spelling, mouse and sketch terms, Solid Fillet name, quoted button and menu names, design plural.
2026-11-10 18:12:27 +00:00
0rdnungsamt 46bb71b5e1 UI kit: menu items can show a host's icon, not only a built-in one (#13379) (#14012)
* claim: #13379

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI kit: menu items can show a host's icon, not only a built-in one

MenuItem takes the kit's Icon: a kit icon name, a host texture or a
paint function. Item owns its data, so it holds an ItemIcon, which still
reads and reports as the kit name. Named icons keep their danger, ink and
plain painting, so existing menus draw as before.

Closes #13379

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Menu test: use the kit's own test context after the move to solidcraft-ui-kit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:03:00 +00:00
SS-EN 18928334c7 Control channel: control.cancel cancels an agent's running or queued request; MCP cancel tool (#1989) (#3754)
* Control channel: control.cancel and the MCP cancel tool (#1989)

A client can cancel its own outstanding request (the one control.pending
reports). Each control request carries a ticket shared with its
connection; whoever moves it first wins:
- still waiting its turn: removed, never runs;
- computing off the window thread (design change, export, agent cursor):
  stopped as Stop stops it, nothing applied, the queue goes on;
- running on the window thread: "cannot be cancelled", it finishes.

The worker is told to stop too: the evaluation skips features it has not
reached, kernel booleans give up at their next try, and work done after
a cancel is never cached. A connection can only cancel its own requests.

MCP: a `cancel` tool sends control.cancel on the bridge's connection;
headless refuses. With #2901's waits: `cancel` goes out at once, not
behind the request still running as other calls are, and once the app
has removed, stopped or answered it, later calls no longer wait for it.
The timeout reply names control.cancel next to control.pending.
With #3654's control.running: a request carries both its `started` flag
and its cancel ticket; one cancelled while it waits is refused before it
is marked started, and stays listed until the window drops it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Control protocol docs: a cancel's wait is 3600 s for engine.batch and the agent chat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:47:32 +00:00
Umut Acil 709133a27a Viewport: highlight sketches on hover in the Browser and Timeline (#14263) (#14278)
* claim: #14263

* Viewport: highlight sketches on hover in the Browser and Timeline (#14263)

Hovering a sketch or feature row in the Browser tree or a sketch in the
History timeline highlights its geometry over the model in the 3D viewport,
clearing the highlight when the pointer moves away.

* Address CodeRabbit review on semantic_content feature hover ownership

* docs: add docstrings to touched functions for coverage threshold

* style: format touched lines with rustfmt
2026-11-10 17:39:42 +00:00
SS-EN faaa92281a Refuse an ambiguous target name taken by another component's body (#12141) (#12172)
* Refuse a target name another component's body holds when this component's body asked for it (#12141)

A body asked to be called a name a linked component's body already has is
named otherwise (names stay unique), and a later feature that targeted the
name ran its boolean on the linked body, failing with a misleading kernel
error. The target is now refused as ambiguous, naming both bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Match a body to its own stored-name slot; refuse ambiguous targets before sheet machining (#12141)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Find the names bodies were asked for in one pass (#12141)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:31:18 +00:00
bherbruck d8ef6dcf15 ops: a loft guided by rails and a blend through cross-sections reach the kernel (#3140) (#14645)
sweep::railed::loft_rails takes sections (a plane point, its normal, the curves) and one or two
rails to the kernel's certified loft (sweep::loft_rails): the caller's tolerance and most wires,
capped into a solid or a sheet, the kernel's refusal past the wires as
Error::RailPastTolerance { asked, reached, wires }. sweep::railed::loft_blend makes the one-face
sheet of the kernel's section blend, which keeps its definition. Ops converts and maps; it
computes no geometry. RailFit (Scale, Stretch) is ops' own here.

The kernel: a section's line curve takes its NURB on a copy; a span is bounded on the surface
both its rail ends lie nearest (a rail along an edge between two faces lies on both); halving
stops where the rail's middle is itself off the surface past the tolerance (it only steers), and
a span past its most pieces is unproved (an infinite bound), not an error.

Measured at ops level on SolidCraft's rail-loft cases (crates/kernel/src/loft_rails_tests.rs and
crates/engine/src/cmd/loft_rails_tests.rs, rebuilt read-only, at SolidCraft's tolerance of 1e-4
of the sections' diagonal and its 33 wires): all sixteen keep their verdict. The twelve that
build match SolidCraft's closed forms within its bars (relative 7e-12 to 7.5e-5 against 1e-4 to
2e-3); the four it refuses are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:30:50 +00:00
bherbruck f56114219b STEP: delete the step-import crate; the goldens and step-export read through the record's Part 21 parser (#14194)
* Move geom's pcurve, label and check into solidcore::eval (pure git mv)

crates/geom/src/brep/{pcurve,pcurve_tests,label,check}.rs move unchanged to
solidcore/crates/solidcore/src/eval/. The next commits retype them to the kernel's evaluation
types and switch their callers; this commit is the move alone so their history follows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval: pcurve, label and check on the kernel's evaluation types

The moved files take eval::geometry's Curve, Curve2, Surface and Frame (the record's kinds,
field for field) and the kernel's evaluator, in place of the record's types and
solidcraft-geom's evaluator. What reads a record as a record leaves: the structural checks
(counts, budgets, a use's interval in its curve's natural range), the edge and face readers
(edge_curve_interval, form_turns on a Face) and the trim request types go to solidcore-ops,
which converts. Results are unchanged: the pcurve keeps its own pole test (the evaluator's
speed rule), knot span search and B-spline cut, not the kernel's collapse; the 80 eval tests
pass, the pcurve suite among them.

Lint-driven rewrites that keep each result: checked indexing, no pre-reserving from input counts,
the loop rotation without a remainder, a clamp that cannot panic on a reversed domain. The
hostile-record cases that depend on the record's checks are kept as no-panic checks here and as
refusals in ops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-ops: pcurve, label and record_check over the record

The record's side of the moved kernel routines: each checks the record's structure as the
record's validation does (one budget per face, a use's interval in its curve's natural range, a
given pcurve as a 2D B-spline), converts through solidcore-convert, calls solidcore::eval and
converts back. The trim request types (TrimFace, TrimEdge, TrimUse, Trims) hold records, so they
live here. No arithmetic: ops' float-arithmetic and geometry-method lints pass unchanged.

The hostile-record refusals the record checks give (empty and non-finite intervals, a NaN
coordinate, a circle of no radius) are tested here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Call pcurve, label and the record checks through solidcore-ops

geom, kernel, smlib and step call solidcore_ops::{pcurve, label, record_check} where they called
solidcraft_geom::brep::{pcurve, label, check}, which moved into solidcore::eval. One
implementation of each remains, in the kernel; geom's brep module no longer declares them.
solidcore-ops becomes a workspace dependency of the four crates (the seam check's public list
already allows it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move geom's record agreement check into solidcore::eval (pure git mv)

crates/geom/src/brep/geometry.rs moves unchanged to solidcore/crates/solidcore/src/eval/agreement.rs
(the kernel's eval already has a geometry module). The next commit retypes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval::agreement on a plain view; ops reads a record into it

The moved check reads a record through agreement::View (edges, vertices, faces and their uses,
in the kernel's evaluation types) in place of the record's Brep, which the kernel never sees.
solidcore-ops' record_check::agreement converts a record's curves, surfaces and pcurves into the
view and calls it; Mismatch, Problem and Record keep their wording. The pole test is the
pcurve's speed rule, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check record agreement through solidcore-ops

kernel, smlib, step and geom's tests call solidcore_ops::record_check::agreement where they
called solidcraft_geom::brep::geometry::check, which moved into solidcore::eval::agreement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave kernel/src/tess on geom's pcurve path until the tess move

crates/kernel/src/tess is moving into solidcore-tess in its own pull request, so its three
pcurve::eval call sites are not edited here: geom::brep::pcurve re-exports
solidcore_ops::pcurve::eval for them, and goes when the tess move switches them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore/bench/step: the STEP reader bake-off harness

fetch.sh builds the corpus at run time from pinned sources (SolidCraft's STEP tests,
dan335/step-conformance, solidcore's fixtures, cadmpeg's fixtures, the NIST PMI and STEP Tools
sets through their SHA-256 pinned fetch scripts), folded by SHA-256; nothing downloaded is
committed. run.py runs readers in the solidcraft-cli eval shape with a timeout and writes
per-file.tsv and summary.md: opened, refused, timed out, all bodies closed, reference volumes
within 0.1% (references.tsv, from formulas), and wall time.

On main cc55f59bf2: 319 files, 195 opened, 135 with every body closed, 23 of 24 reference
volumes, median 0.20 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move SolidCraft's STEP reader into solidcore (pure git mv)

crates/step moves unchanged to solidcore/crates/solidcore-step, and its Part 21 parser to
solidcore-record/src/step/p21.rs, where it takes the place of record's earlier port of this same
file (from a5b71ca2). This commit is the move alone, so the files keep their history and
authors; the next commits make them build in their new places and switch SolidCraft to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-record: the Part 21 parser keeps record's hardening

The moved parser takes back what record's port added in review (#2591): typed errors with the
same messages, entities in id order (BTreeMap), capped parameter and record counts, linear-time
decoding of unterminated escapes, a byte order mark skipped. The file's content is record's as
before the move; its history now runs through SolidCraft's parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: the moved reader as a solidcore workspace crate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: evaluate and form records through the kernel

The moved reader called solidcraft-geom's record evaluator, exact forms and vector helpers. It
now calls the kernel's: solidcore::eval's CurveEval, SurfaceEval, face_flux and form, through a
private `kernel` module that converts records with solidcore-convert, and the kernel's vector
measures on the record's Vec3 (`vec`). geom's form was a copy of the kernel's, function for
function; the face box (`extent`) is the kernel's with poles taken exactly. The B-spline cut is
the kernel's (eval::pcurve::segment), the pole test the pcurve's speed rule, both made public
for this. Profiling spans, which were SolidCraft's, are left out.

The crate keeps SolidCraft's formatting (its own rustfmt.toml) and lints under solidcore's hard
rules: no unsafe, unwrap, expect, panic or unchecked indexing (the 33 indexing sites are
rewritten with iterators and checked access, results unchanged). The step-conformance test reads
SolidCraft's tests/step-conformance where the tree has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* SolidCraft reads and writes STEP through solidcore-step

crates/kernel's STEP import (step_in), STEP export (step_out) and IGES export (iges_out) call
solidcore_step where they called solidcraft_step; crates/step is gone. The Part 21 parse error is
the record's typed error, shown with the same message. solidcore-step joins the crates
SolidCraft may use directly (xtask/solidcore-public.txt) until its build is kernel primitives
under an ops recipe (RFC 0003, 1.6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: bound faces with the pcurves' pole test (form::extent_by)

The bake-off showed the reader on the kernel's form::extent refusing 7 STEP Tools parts
(304-310 boxy_with_*: "loop encloses no (u, v) area") and reading the large parts 2-8x slower
(rear 37.8 s against 18.4 s): extent's exact collapse test classifies the boundary points
differently from the speed rule the reader's faces were always bounded with, and costs a
collapse search per point (the cost in #13655). extent's body now takes the pole test
(form::extent_by; extent keeps the exact one), and the reader passes the pcurves' speed rule.
On those files the result and time are base's again: boxy_with_cylindricity 174839.178,
nist_ftc_06 3323556.516, rear 14.0 s against 16.3 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Kernel rig: drop the gone crates/step paths

crates/step moved to solidcore/crates/solidcore-step, which the shared core (PATHS-ALL:
solidcore/**) already covers: the import and crash areas' PATHS and kernel-rig.yml drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a seam round the tube for a torus face winding around its axis

A torus face bounded by two circles about its axis (a half torus between its rims, the fillet
round a bore) was refused: "a face winding around a torus ... without a seam edge". It now gets
a seam as a band on a cylinder or sphere does, a tube circle at a vertex's angle, running from
the loop the face lies above up the tube to the other loop, so on the face's side whichever half
of the tube that is. On the bake-off 9 bodies that were open shells close: torus_ring (4934.802,
its reference), turbine, nist_ctc_05 (both editions), nist_ftc_06 rd, nist_ftc_11 (both),
nist_stc_06, nist_stc_10; every body closed goes 135 -> 145, reference volumes 23 -> 24 of 24.

bench/step/run.py takes a probe-shaped reader (LABEL=probe:COMMAND, one JSON line) for a
library without SolidCraft's CLI, and runs every reader on a file together so they meet the same
load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: short entity names, POLY_LOOP, FACETED_BREP and plain FACE

What solidcore's step-import read and this reader refused:
- Part 21 short names (CRTPNT, FCSRF, PLYLP, ...) read as their long names, in the record's
  parser (step-import's table, #2582). STEP Tools' clip and socks are written that way.
- A POLY_LOOP bound is a closed polygon: one vertex per point and one line edge per side, an edge
  shared by every loop with the same two points.
- A FACETED_BREP is a manifold solid of such faces (calo, cylcub).
- A plain FACE, with no surface, lies on the plane of its loops (Newell's normal, refused where
  the loop leaves the plane by more than the tolerance).

Tests: a faceted tetrahedron closes with 6 shared edges; short names parse to long ones; the
torus band seam from the commit before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: read topology outside representations, lines by their vertices, inner loops by area

Topology-first reading, as solidcore's step-import and step-brep read files (#2583):
- Topology no representation lists (bare geometry files): crates/kernel reads the file's
  unlisted solids, then its unlisted shells and faces (each face a one-face sheet), through
  solidcore_step::topology::roots, in the file's one representation context. A representation's
  own shell and face items stay out (a presentation's are not bodies).
- A LINE edge whose vertices lie off its LINE (a direction or magnitude written wrong) is the
  segment between its vertices, which the faces it bounds share; other curves still refuse a far
  vertex.
- An inner loop written the way its outer loop runs, on a face whose loops close in (u, v), is
  run the other way.
- More Part 21 short names (product structure, representations, units, FACETED_BREP).

Bake-off (319 files, 220 opened by some reader): opened 195 -> 212, every body closed 135 ->
148, reference volumes 24 of 24 (core 22); median time unchanged (0.123 s -> 0.119 s).
kernel-cases/import 35/35; the kernel's step_in, IGES and lump tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a sphere face bounded by vertex loops at its poles is the whole sphere

A face of a sphere whose bounds are vertex loops alone, at its poles (cadmpeg's
ap242_vertex_loop), was refused as "a face without an edge loop". It is the whole sphere: its
loop becomes a meridian from the south pole to the north and back, which the slit completion
keeps as its seam, as a ball bounded by its seam alone already is. Any other face without an
edge loop is still refused. Test: a ball bounded by vertex loops at its poles closes with one
seam edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a face SMLib refuses is named and left out; a face used twice is one piece

Opening a file never fails over faces that cannot be made: the face is left out, the rest come in
as an open body, and the import's warnings name each face left out with why.
- SMLib's refusal of a record face by its index ("flat arrays: face 90: a NURBS form that is not
  its analytic surface") now names the file's face (face #1202), so it is left out like a face
  the reader refuses. NIST CTC 02 (AP203) opens: 480 of its 487 faces as an open body, the 4
  faces 622 mm off their surfaces and the one SMLib refuses named in the warnings.
- A face the solid's shells use more than once (cadmpeg br02: one face as the outer shell and two
  voids) is read once, and its further uses are named as left out.
- One face read alone is the sheet itself (nothing to stitch).

bench/step/run.py counts the files opened with faces left out, from the import warnings.
Test: a face SMLib refuses by its index is named as the file's face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a solid whose only face cannot be made says which face, why and how far

A file whose one face cannot be made (cadmpeg's pc01, pc03 and tp06 non-coplanar fixtures:
edges 1 to 10 mm off the face's surface) has nothing consistent to open. Its error now reads
"the solid's only face, #30, cannot be made: an edge lies 1 mm off the face's surface" in place of
the count of faces whose pcurves failed. Test: a disk whose circle lies 1.5 mm off its plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: an unclamped B-spline curve is read clamped to its domain

A closed curve written as an unclamped B-spline (uniform knots past its ends, its first points
repeated at its end: cadmpeg's N04 recipes, periodic writers) had no closed-form pcurve on a
plane, so its faces were left out: "no exact pcurve of a B-spline on a plane". It is now read
as the same curve over [knots[p], knots[n]], clamped there by exact knot insertion
(eval::pcurve::segment), as every evaluator and pcurve takes a spline. recipe N04a and N04b open
as 3 closed solids each (5000 and 6000 mm³, as solidcore's step-brep reads them).

Test: a disk bounded by an unclamped periodic quadratic reads, its edge clamped to [0, 4].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a seam round the axis for a torus face winding round its tube

A torus face bounded by two circles round its tube (an elbow's wall between its two ends) was
refused: "a loop winding around a surface more than once or in v". Its loops wind in v, so it
gets a seam the other way about: a circle about the axis at a vertex's angle round the tube,
running from the loop that winds up the tube towards −u (the face lies to the left of its loops)
to the other. band_seam and crossing take the parameter the loops wind in. In STEP Tools'
jack_in_the_box 14 bodies that were open shells close (34 -> 48 of 56).

Test: a quarter elbow closes with a quarter-circle seam about its axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move step-brep's export mapping into step-export, its fixtures into solidcore-step (pure git mv)

brep_to_model (a kernel body as step-import's model, for the writer) moves to step-export, which
RFC 0003 item 1.7 measures against SolidCraft's writer; the two STEP fixtures move to
solidcore-step's tests. The move alone, so their history follows them; the next commit deletes
step-brep's import side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete step-brep's STEP import: one STEP reader, solidcore-step

solidcore had two STEP readers; solidcore-step (SolidCraft's, moved in #13979) now reads what
step-brep's topology-first build read, but the faces it would have to build inconsistently
(#14038's gate). step-brep's import side goes: its build, constructor and UV trims (lib.rs,
constructor.rs, pcurves.rs) and their tests. What it kept for the writer, brep_to_model, is
step-export's now (RFC 0003 item 1.7); its round-trip cases that read back through the deleted
importer go with it. The panic-site list drops step-brep's rows and moves brep_to_model's.

Ported, with their names: the Discord fillet operand reads as one closed solid, and a face no
shell lists is a one-face sheet (crates/kernel step_in tests); rod_doubled_edge names the face
of its folded rim (solidcore-step). The fixtures moved with their history. The reading cases of
step-brep's tests, written by Brennen Herbruck (topology_first, issue_2580_winding_faces,
issue_2581_faceted_brep, issue_2583_topology_first, discord_fillet), are covered by
solidcore-step's tests of seams, tori, faceted solids, unlisted topology and the bake-off.

step-import stays for the goldens and step-export until #14123. Core's code for the 7
jack_in_the_box bodies still open (#14130) is at 2a96a046ca.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: port step-brep's import panic fuzz onto solidcore-step

step-brep's tests/panic_fuzz.rs (#2490, by Brennen Herbruck) went with its import. Its cases
come back with their names:
- panic_fuzz_step_import (solidcore-step): the same mutations (truncation, flipped bytes,
  hostile numbers, broken and cyclic references, dropped and repeated lines, swapped entity
  names, deep nesting) of the Discord fillet and tetra fixtures and of primitives this crate's
  writer writes. Each is parsed, read into records, checked as the kernel takes them, made into
  kernel bodies and written again. Also entity_edit_lands_on_an_entity_name, and a check that
  the written bases read back as solids, so the mutations reach the whole path.
- panic_fuzz_step_flat_decode (step-import, whose flat arrays it decodes).

Run: 3 seeds of 20 000 import cases and 5 000 flat cases, 0 findings, 0 hangs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: rustfmt the panic fuzz harness's doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* panic fuzz harness: keep solidcore's formatting where solidcore-step includes it

solidcore-step formats with SolidCraft's rustfmt.toml, which reached the harness through its
#[path] include; the include is #[rustfmt::skip] now, and the harness is solidcore's as on main
but its doc naming the fuzzes that share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP and pcurves: topology short names; a use's interval refused when empty or not finite

From review:
- The record's parser maps the AP203 short names of the topology too (ADVFC, EDGCRV, EDGLP,
  FCBND, MNSLBR, ORNEDG, VRTPNT): a file written with them had its faces refused.
- solidcore::eval::pcurve refuses a use whose interval is empty, reversed or not finite before
  anything else: a given pcurve was checked against the interval, which a NaN end passed, so
  exact_pcurves returned it as a pcurve. Tests: those intervals are Invalid with and without a
  given pcurve; VRTPNT and FCBND read as their long names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-record: short names checked against ISO's short-names file

Every pair of the table is as http://standards.iso.org/iso/10303/tech/short_names/short-names.txt
gives it, the source cited beside it. One was wrong, from step-import's table: CMCRSG is
COMPOSITE_CURVE_SEGMENT, not COMPOSITE_CURVE (CMPCRV, added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: retire the step-import crate; one STEP parser, the record's p21

step-import's Part 21 parser is gone: the goldens' SMLib comparison, the
--step-corpus run and step-export read through solidcore-record's p21. The
neutral model moves to solidcore-record::step::model (plain data), the mapper
from p21 to that model moves to step-export (it computes frames and angles, so
it cannot live in the record), and the constructor script and flat arrays move
into the goldens crate, their only users.

p21's Exchange now keeps the HEADER's file name, authors and so on (step-export
reads them back). The brep_constructor golden is unchanged (185 of 185 cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cargo.lock: solidcraft-step is now solidcore-step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io: the model cache key no longer names crates/step

The reader moved to solidcore/crates/solidcore-step, which the cache already keys as part of the solidcore tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: a whole turn a rounding short of a quarter line has its form from it (#12760)

Port of #13535's fix in crates/geom's form to the kernel's form, which the STEP reader now uses: a cylinder, cone, sphere or torus face of one whole turn whose seam reads back a rounding short of a quarter line started its form a quarter early, and a tube read back through STEP measured 14 % over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* pcurve docs: a pcurve without a closed form is refused, not fitted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench/step: a malformed --reader is an error; a reader's bad JSON is a refusal, not a crash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: SMLib's first refusal names the file's face, on a turned sheet too

A solid SMLib refused by a face's record index ("flat arrays: face 12") had that index named as a file face only on the reads that left faces out, not the first, so where every face read alone nothing was left out and the faces were stitched instead. solid_part names it, with the face order of the record SMLib was given (a sheet's turned faces reorder it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench/step: a reader or corpus file that cannot be run is a crashed row, not a stopped run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a styled or layered loose shell or face is still read

roots() took any reference to a shell or face as its topology's, so a bare face a STYLED_ITEM or a layer assignment names was skipped and the file read as having no bodies. A presentation's references (styles, layers, invisibility) no longer count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:27:56 -05:00
austin62jake 2215a9a50a Ruled Surface: input and output schemas for surface.ruled (#13134) (#13135)
surface.ruled gets SurfaceRuledParams in a new schema/surface_ruled.rs and replies
with FeatureResult. mode and corners are keyword types in schema/types.rs with the
handler's messages; edges, direction, face and far get types of their own that keep
its messages (far is read in order, so the first bad item gives the same message).
angle and direction are checked whenever given; which mode needs them stays the
handler's.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 17:16:14 +00:00
bherbruck 182d2c6bd8 solidcore-ops: cancel ops calls with a scoped token (solidcore#3247) (#14660)
solidcore_ops::cancel::within runs work under a CancelToken. Every ops call
it makes on that thread runs with the token on the session's kernel, whose
long loops already poll it; once cancelled, the running call stops at the
kernel's next check and later calls are refused before they wait for the
kernel, each with Error::Cancelled.

A cancelled call leaves no partial state: an edited body is put back by the
session's rollback, no result is returned even if the kernel finished
first, the kernel is replaced as after a panic, and nothing reaches the
failure callback. Nested scopes stop with either token
(CancelToken::either in the kernel).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:59:20 +00:00
bherbruck 126e91f95b STEP: one reader: topology-first reading in solidcore-step, step-brep's import deleted (RFC 0003, 1.6) (#14038)
* Move geom's pcurve, label and check into solidcore::eval (pure git mv)

crates/geom/src/brep/{pcurve,pcurve_tests,label,check}.rs move unchanged to
solidcore/crates/solidcore/src/eval/. The next commits retype them to the kernel's evaluation
types and switch their callers; this commit is the move alone so their history follows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval: pcurve, label and check on the kernel's evaluation types

The moved files take eval::geometry's Curve, Curve2, Surface and Frame (the record's kinds,
field for field) and the kernel's evaluator, in place of the record's types and
solidcraft-geom's evaluator. What reads a record as a record leaves: the structural checks
(counts, budgets, a use's interval in its curve's natural range), the edge and face readers
(edge_curve_interval, form_turns on a Face) and the trim request types go to solidcore-ops,
which converts. Results are unchanged: the pcurve keeps its own pole test (the evaluator's
speed rule), knot span search and B-spline cut, not the kernel's collapse; the 80 eval tests
pass, the pcurve suite among them.

Lint-driven rewrites that keep each result: checked indexing, no pre-reserving from input counts,
the loop rotation without a remainder, a clamp that cannot panic on a reversed domain. The
hostile-record cases that depend on the record's checks are kept as no-panic checks here and as
refusals in ops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-ops: pcurve, label and record_check over the record

The record's side of the moved kernel routines: each checks the record's structure as the
record's validation does (one budget per face, a use's interval in its curve's natural range, a
given pcurve as a 2D B-spline), converts through solidcore-convert, calls solidcore::eval and
converts back. The trim request types (TrimFace, TrimEdge, TrimUse, Trims) hold records, so they
live here. No arithmetic: ops' float-arithmetic and geometry-method lints pass unchanged.

The hostile-record refusals the record checks give (empty and non-finite intervals, a NaN
coordinate, a circle of no radius) are tested here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Call pcurve, label and the record checks through solidcore-ops

geom, kernel, smlib and step call solidcore_ops::{pcurve, label, record_check} where they called
solidcraft_geom::brep::{pcurve, label, check}, which moved into solidcore::eval. One
implementation of each remains, in the kernel; geom's brep module no longer declares them.
solidcore-ops becomes a workspace dependency of the four crates (the seam check's public list
already allows it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move geom's record agreement check into solidcore::eval (pure git mv)

crates/geom/src/brep/geometry.rs moves unchanged to solidcore/crates/solidcore/src/eval/agreement.rs
(the kernel's eval already has a geometry module). The next commit retypes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval::agreement on a plain view; ops reads a record into it

The moved check reads a record through agreement::View (edges, vertices, faces and their uses,
in the kernel's evaluation types) in place of the record's Brep, which the kernel never sees.
solidcore-ops' record_check::agreement converts a record's curves, surfaces and pcurves into the
view and calls it; Mismatch, Problem and Record keep their wording. The pole test is the
pcurve's speed rule, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check record agreement through solidcore-ops

kernel, smlib, step and geom's tests call solidcore_ops::record_check::agreement where they
called solidcraft_geom::brep::geometry::check, which moved into solidcore::eval::agreement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave kernel/src/tess on geom's pcurve path until the tess move

crates/kernel/src/tess is moving into solidcore-tess in its own pull request, so its three
pcurve::eval call sites are not edited here: geom::brep::pcurve re-exports
solidcore_ops::pcurve::eval for them, and goes when the tess move switches them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore/bench/step: the STEP reader bake-off harness

fetch.sh builds the corpus at run time from pinned sources (SolidCraft's STEP tests,
dan335/step-conformance, solidcore's fixtures, cadmpeg's fixtures, the NIST PMI and STEP Tools
sets through their SHA-256 pinned fetch scripts), folded by SHA-256; nothing downloaded is
committed. run.py runs readers in the solidcraft-cli eval shape with a timeout and writes
per-file.tsv and summary.md: opened, refused, timed out, all bodies closed, reference volumes
within 0.1% (references.tsv, from formulas), and wall time.

On main cc55f59bf2: 319 files, 195 opened, 135 with every body closed, 23 of 24 reference
volumes, median 0.20 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move SolidCraft's STEP reader into solidcore (pure git mv)

crates/step moves unchanged to solidcore/crates/solidcore-step, and its Part 21 parser to
solidcore-record/src/step/p21.rs, where it takes the place of record's earlier port of this same
file (from a5b71ca2). This commit is the move alone, so the files keep their history and
authors; the next commits make them build in their new places and switch SolidCraft to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-record: the Part 21 parser keeps record's hardening

The moved parser takes back what record's port added in review (#2591): typed errors with the
same messages, entities in id order (BTreeMap), capped parameter and record counts, linear-time
decoding of unterminated escapes, a byte order mark skipped. The file's content is record's as
before the move; its history now runs through SolidCraft's parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: the moved reader as a solidcore workspace crate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: evaluate and form records through the kernel

The moved reader called solidcraft-geom's record evaluator, exact forms and vector helpers. It
now calls the kernel's: solidcore::eval's CurveEval, SurfaceEval, face_flux and form, through a
private `kernel` module that converts records with solidcore-convert, and the kernel's vector
measures on the record's Vec3 (`vec`). geom's form was a copy of the kernel's, function for
function; the face box (`extent`) is the kernel's with poles taken exactly. The B-spline cut is
the kernel's (eval::pcurve::segment), the pole test the pcurve's speed rule, both made public
for this. Profiling spans, which were SolidCraft's, are left out.

The crate keeps SolidCraft's formatting (its own rustfmt.toml) and lints under solidcore's hard
rules: no unsafe, unwrap, expect, panic or unchecked indexing (the 33 indexing sites are
rewritten with iterators and checked access, results unchanged). The step-conformance test reads
SolidCraft's tests/step-conformance where the tree has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* SolidCraft reads and writes STEP through solidcore-step

crates/kernel's STEP import (step_in), STEP export (step_out) and IGES export (iges_out) call
solidcore_step where they called solidcraft_step; crates/step is gone. The Part 21 parse error is
the record's typed error, shown with the same message. solidcore-step joins the crates
SolidCraft may use directly (xtask/solidcore-public.txt) until its build is kernel primitives
under an ops recipe (RFC 0003, 1.6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: bound faces with the pcurves' pole test (form::extent_by)

The bake-off showed the reader on the kernel's form::extent refusing 7 STEP Tools parts
(304-310 boxy_with_*: "loop encloses no (u, v) area") and reading the large parts 2-8x slower
(rear 37.8 s against 18.4 s): extent's exact collapse test classifies the boundary points
differently from the speed rule the reader's faces were always bounded with, and costs a
collapse search per point (the cost in #13655). extent's body now takes the pole test
(form::extent_by; extent keeps the exact one), and the reader passes the pcurves' speed rule.
On those files the result and time are base's again: boxy_with_cylindricity 174839.178,
nist_ftc_06 3323556.516, rear 14.0 s against 16.3 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Kernel rig: drop the gone crates/step paths

crates/step moved to solidcore/crates/solidcore-step, which the shared core (PATHS-ALL:
solidcore/**) already covers: the import and crash areas' PATHS and kernel-rig.yml drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a seam round the tube for a torus face winding around its axis

A torus face bounded by two circles about its axis (a half torus between its rims, the fillet
round a bore) was refused: "a face winding around a torus ... without a seam edge". It now gets
a seam as a band on a cylinder or sphere does, a tube circle at a vertex's angle, running from
the loop the face lies above up the tube to the other loop, so on the face's side whichever half
of the tube that is. On the bake-off 9 bodies that were open shells close: torus_ring (4934.802,
its reference), turbine, nist_ctc_05 (both editions), nist_ftc_06 rd, nist_ftc_11 (both),
nist_stc_06, nist_stc_10; every body closed goes 135 -> 145, reference volumes 23 -> 24 of 24.

bench/step/run.py takes a probe-shaped reader (LABEL=probe:COMMAND, one JSON line) for a
library without SolidCraft's CLI, and runs every reader on a file together so they meet the same
load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: short entity names, POLY_LOOP, FACETED_BREP and plain FACE

What solidcore's step-import read and this reader refused:
- Part 21 short names (CRTPNT, FCSRF, PLYLP, ...) read as their long names, in the record's
  parser (step-import's table, #2582). STEP Tools' clip and socks are written that way.
- A POLY_LOOP bound is a closed polygon: one vertex per point and one line edge per side, an edge
  shared by every loop with the same two points.
- A FACETED_BREP is a manifold solid of such faces (calo, cylcub).
- A plain FACE, with no surface, lies on the plane of its loops (Newell's normal, refused where
  the loop leaves the plane by more than the tolerance).

Tests: a faceted tetrahedron closes with 6 shared edges; short names parse to long ones; the
torus band seam from the commit before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: read topology outside representations, lines by their vertices, inner loops by area

Topology-first reading, as solidcore's step-import and step-brep read files (#2583):
- Topology no representation lists (bare geometry files): crates/kernel reads the file's
  unlisted solids, then its unlisted shells and faces (each face a one-face sheet), through
  solidcore_step::topology::roots, in the file's one representation context. A representation's
  own shell and face items stay out (a presentation's are not bodies).
- A LINE edge whose vertices lie off its LINE (a direction or magnitude written wrong) is the
  segment between its vertices, which the faces it bounds share; other curves still refuse a far
  vertex.
- An inner loop written the way its outer loop runs, on a face whose loops close in (u, v), is
  run the other way.
- More Part 21 short names (product structure, representations, units, FACETED_BREP).

Bake-off (319 files, 220 opened by some reader): opened 195 -> 212, every body closed 135 ->
148, reference volumes 24 of 24 (core 22); median time unchanged (0.123 s -> 0.119 s).
kernel-cases/import 35/35; the kernel's step_in, IGES and lump tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a sphere face bounded by vertex loops at its poles is the whole sphere

A face of a sphere whose bounds are vertex loops alone, at its poles (cadmpeg's
ap242_vertex_loop), was refused as "a face without an edge loop". It is the whole sphere: its
loop becomes a meridian from the south pole to the north and back, which the slit completion
keeps as its seam, as a ball bounded by its seam alone already is. Any other face without an
edge loop is still refused. Test: a ball bounded by vertex loops at its poles closes with one
seam edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a face SMLib refuses is named and left out; a face used twice is one piece

Opening a file never fails over faces that cannot be made: the face is left out, the rest come in
as an open body, and the import's warnings name each face left out with why.
- SMLib's refusal of a record face by its index ("flat arrays: face 90: a NURBS form that is not
  its analytic surface") now names the file's face (face #1202), so it is left out like a face
  the reader refuses. NIST CTC 02 (AP203) opens: 480 of its 487 faces as an open body, the 4
  faces 622 mm off their surfaces and the one SMLib refuses named in the warnings.
- A face the solid's shells use more than once (cadmpeg br02: one face as the outer shell and two
  voids) is read once, and its further uses are named as left out.
- One face read alone is the sheet itself (nothing to stitch).

bench/step/run.py counts the files opened with faces left out, from the import warnings.
Test: a face SMLib refuses by its index is named as the file's face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a solid whose only face cannot be made says which face, why and how far

A file whose one face cannot be made (cadmpeg's pc01, pc03 and tp06 non-coplanar fixtures:
edges 1 to 10 mm off the face's surface) has nothing consistent to open. Its error now reads
"the solid's only face, #30, cannot be made: an edge lies 1 mm off the face's surface" in place of
the count of faces whose pcurves failed. Test: a disk whose circle lies 1.5 mm off its plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: an unclamped B-spline curve is read clamped to its domain

A closed curve written as an unclamped B-spline (uniform knots past its ends, its first points
repeated at its end: cadmpeg's N04 recipes, periodic writers) had no closed-form pcurve on a
plane, so its faces were left out: "no exact pcurve of a B-spline on a plane". It is now read
as the same curve over [knots[p], knots[n]], clamped there by exact knot insertion
(eval::pcurve::segment), as every evaluator and pcurve takes a spline. recipe N04a and N04b open
as 3 closed solids each (5000 and 6000 mm³, as solidcore's step-brep reads them).

Test: a disk bounded by an unclamped periodic quadratic reads, its edge clamped to [0, 4].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a seam round the axis for a torus face winding round its tube

A torus face bounded by two circles round its tube (an elbow's wall between its two ends) was
refused: "a loop winding around a surface more than once or in v". Its loops wind in v, so it
gets a seam the other way about: a circle about the axis at a vertex's angle round the tube,
running from the loop that winds up the tube towards −u (the face lies to the left of its loops)
to the other. band_seam and crossing take the parameter the loops wind in. In STEP Tools'
jack_in_the_box 14 bodies that were open shells close (34 -> 48 of 56).

Test: a quarter elbow closes with a quarter-circle seam about its axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move step-brep's export mapping into step-export, its fixtures into solidcore-step (pure git mv)

brep_to_model (a kernel body as step-import's model, for the writer) moves to step-export, which
RFC 0003 item 1.7 measures against SolidCraft's writer; the two STEP fixtures move to
solidcore-step's tests. The move alone, so their history follows them; the next commit deletes
step-brep's import side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete step-brep's STEP import: one STEP reader, solidcore-step

solidcore had two STEP readers; solidcore-step (SolidCraft's, moved in #13979) now reads what
step-brep's topology-first build read, but the faces it would have to build inconsistently
(#14038's gate). step-brep's import side goes: its build, constructor and UV trims (lib.rs,
constructor.rs, pcurves.rs) and their tests. What it kept for the writer, brep_to_model, is
step-export's now (RFC 0003 item 1.7); its round-trip cases that read back through the deleted
importer go with it. The panic-site list drops step-brep's rows and moves brep_to_model's.

Ported, with their names: the Discord fillet operand reads as one closed solid, and a face no
shell lists is a one-face sheet (crates/kernel step_in tests); rod_doubled_edge names the face
of its folded rim (solidcore-step). The fixtures moved with their history. The reading cases of
step-brep's tests, written by Brennen Herbruck (topology_first, issue_2580_winding_faces,
issue_2581_faceted_brep, issue_2583_topology_first, discord_fillet), are covered by
solidcore-step's tests of seams, tori, faceted solids, unlisted topology and the bake-off.

step-import stays for the goldens and step-export until #14123. Core's code for the 7
jack_in_the_box bodies still open (#14130) is at 2a96a046ca.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: port step-brep's import panic fuzz onto solidcore-step

step-brep's tests/panic_fuzz.rs (#2490, by Brennen Herbruck) went with its import. Its cases
come back with their names:
- panic_fuzz_step_import (solidcore-step): the same mutations (truncation, flipped bytes,
  hostile numbers, broken and cyclic references, dropped and repeated lines, swapped entity
  names, deep nesting) of the Discord fillet and tetra fixtures and of primitives this crate's
  writer writes. Each is parsed, read into records, checked as the kernel takes them, made into
  kernel bodies and written again. Also entity_edit_lands_on_an_entity_name, and a check that
  the written bases read back as solids, so the mutations reach the whole path.
- panic_fuzz_step_flat_decode (step-import, whose flat arrays it decodes).

Run: 3 seeds of 20 000 import cases and 5 000 flat cases, 0 findings, 0 hangs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: rustfmt the panic fuzz harness's doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* panic fuzz harness: keep solidcore's formatting where solidcore-step includes it

solidcore-step formats with SolidCraft's rustfmt.toml, which reached the harness through its
#[path] include; the include is #[rustfmt::skip] now, and the harness is solidcore's as on main
but its doc naming the fuzzes that share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP and pcurves: topology short names; a use's interval refused when empty or not finite

From review:
- The record's parser maps the AP203 short names of the topology too (ADVFC, EDGCRV, EDGLP,
  FCBND, MNSLBR, ORNEDG, VRTPNT): a file written with them had its faces refused.
- solidcore::eval::pcurve refuses a use whose interval is empty, reversed or not finite before
  anything else: a given pcurve was checked against the interval, which a NaN end passed, so
  exact_pcurves returned it as a pcurve. Tests: those intervals are Invalid with and without a
  given pcurve; VRTPNT and FCBND read as their long names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-record: short names checked against ISO's short-names file

Every pair of the table is as http://standards.iso.org/iso/10303/tech/short_names/short-names.txt
gives it, the source cited beside it. One was wrong, from step-import's table: CMCRSG is
COMPOSITE_CURVE_SEGMENT, not COMPOSITE_CURVE (CMPCRV, added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cargo.lock: solidcraft-step is now solidcore-step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io: the model cache key no longer names crates/step

The reader moved to solidcore/crates/solidcore-step, which the cache already keys as part of the solidcore tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: a whole turn a rounding short of a quarter line has its form from it (#12760)

Port of #13535's fix in crates/geom's form to the kernel's form, which the STEP reader now uses: a cylinder, cone, sphere or torus face of one whole turn whose seam reads back a rounding short of a quarter line started its form a quarter early, and a tube read back through STEP measured 14 % over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* pcurve docs: a pcurve without a closed form is refused, not fitted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench/step: a malformed --reader is an error; a reader's bad JSON is a refusal, not a crash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: SMLib's first refusal names the file's face, on a turned sheet too

A solid SMLib refused by a face's record index ("flat arrays: face 12") had that index named as a file face only on the reads that left faces out, not the first, so where every face read alone nothing was left out and the faces were stitched instead. solid_part names it, with the face order of the record SMLib was given (a sheet's turned faces reorder it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench/step: a reader or corpus file that cannot be run is a crashed row, not a stopped run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:21:45 -05:00
bherbruck 54e5190955 Overlapping cylinders' round builds again: the rail end starts from the round's own section (#13538) (#13898)
* claim: #13537 and #13538, rim cut and overlapping-cylinder fillet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* blend: a rail's end at a side edge starts from the round's corner section (#13538)

A round of either crossing edge of two crossing cylinders (SolidCraft's
fillet_the_edges_between_two_overlapping_cylinders, R 13) was refused with
status 1001: CalcFilletIntSideFace found no intersection of the round with
the cap. With the walls' offsets continued as themselves (whole circles),
the faces hold two balls at each cap edge, the round of each crossing.
CalcRailIntEdgeuse's first guess on the small wall's cap edge, upstream's
radius fraction along the edge, solved the rail end onto the other
crossing's ball (contact at (4.02, 20.35) instead of (18.98, 8.37)), so the
cap curve between the two rail ends did not exist.

The first guess is now where the ball's section through the filleted
edge's end touches the side edge: the section is solved from the edge
point on both faces, so it is the ball of this round (the start of a
rolling-ball march; Choi and Ju 1989). The section solve is
ball_fit::edge_section, shared with ball_fits_its_faces. Upstream's other
guesses follow unchanged.

This also covers #3045 (the plate's arc end): its test passes with the
face-domain clamp on the drop removed, and that clamp is what broke this
case at a445bdaac6..c0d3cc0124 (b11c5554b3): it moved the drop's guess to
the face's end, from where the solve also found the other ball. One
construction, so the clamp goes.

Goldens, blend.fillet_executive_p3, 12 cases moved (every other blend
unit unchanged):
- radius-supported-at-limit-accepted: built (volume 2199.999313) ->
  refused 1001 (input kept, 2200). The rail end moves one ulp
  (29.999920000080003 -> 29.99992000008); the case sits at the radius
  limit. The old body was off its closed form (2200 - 4e-8) by 6.9e-4.
- stubs4-coons_01_03_29-e0-r0.5: refused 1001 -> built (8 vertices, 12
  edges, 5 faces; mass status 1007 before and after: not a solid).
- ten others: same status and counts; mass properties move at most
  4e-9 relative; in four (issue_2176 tab side, issue-880 half-arc 1-1 and
  1.4437, stubs4-coons e3) the curves' and surfaces' digests change too
  (the rail ends' parameters converge to other roundings).

New test: solidcore-ops round_between_crossing_cylinders (R 6 at each
crossing, both, and R 13), closed-form volumes; fails without this change
(R 13: Status(1001)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* blend: a variable round too large for a box edge is refused with the radius that fits

a_failed_variable_radius_fillet_keeps_its_error_and_a_current_body (radius
0.5 to 30 along an edge of the 10 x 20 x 30 box) was refused with a bare
status 1001; with the rail end continued from the corner section it is
refused as FilletRefused, same status, naming radius 10 as the largest
that builds (the 10 wide side). The body is kept as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Known failing: the overlapping cylinders' round builds again (#13538)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* blend: a rail end on an offset continued as itself is solved on its closed turn (#13538)

solidcore's issue_1945_rail_uv_trim top_edge_round_is_valid failed after
the rail end started from the round's corner section: the round of the
bar's top edge, ending on the boss's arc round, was refused ("a round's
rail leaves the face it rolls on into the body's material"). The rail
end's solve found the right point (side-edge parameter 0.99805 as before)
but named it on the arc round's offset one generator turn away: v
-3.3445 instead of 0.6555, which the face's trim reads as off the face.

LoadInitialValues solved the other surface on its base domain grown by
SetupOffsetExtension, several turns of a surface that continues as
itself. The extension names each point once inside its closed turn: round
the axis it wraps past the turn (WR-650), and along a circle generator (a
torus's, a revolved circle's) the closed circle holds every point. Those
directions are now solved there (closed_turn): round the axis periodic on
the turn, along the generator bounded half the gap past either end (its
extension spans are quarter turns, the base's need not be, so it is not
wrapped).

Every blend unit's goldens are unchanged from the previous commit's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* blend: drop the import main's definition change left unused (#13538)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge origin/main; goldens: the round at the minimum radius is known failing (tangency-labs/solidcore#3245)

radius-supported-at-limit-accepted depends on rounding: the round and its
end arcs lie r(sqrt 2 - 1) = 3.3e-5 from the corner they replace, inside
the 5e-5 of the corner vertex's and the fillet's tolerances, so whether the
end arcs are imprinted flips with ulp-level changes (it came out
[1001,8,12,6] on the merge with main where it was blessed [1001,12,18,8]).
It is listed in known_failing.json, without a golden line, until #3245.
PASS blend.fillet_executive_p3 cases=798 changed=0 known_failing=5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:05:07 -05:00
bherbruck 6ca950789c Extruding a long outline: its end faces build about 1.5× faster (a face edit no one listens to skips the edge-use walk) (#14329) (#14454)
* solidcore: building a cap face of a long outline stops walking the whole face for each edge (#14329)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: an unheard face edit skips walking the face's edge uses (#14329)

Each edge added to a face notifies the face (PostEdit), which walked every
edge use and vertex use of the face. An edge use's PostEdit changes nothing
and only a context notify callback can see it, so without one the edge-use
walk is skipped. Building the cap of an outline of N lines was O(N^2) in
that walk; the cap of a 12,000-line outline builds in 10.4 s instead of
15.2 s, with the same face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:40:56 +00:00
Andrzej Błasik 89e3cb13b1 Faster rebuilds: extrusions, revolutions and sweeps name their new faces from exact geometry instead of meshing the body (#14646)
Each new face of an extrusion, revolution, sweep, loft or screw sweep is
named (start cap, end cap, or the wall swept from a sketch segment) so later
features find it again. The kernel meshed every new body to work the names
out; on extrusion-heavy designs that mesh was about a third of the rebuild.

The names now come from the exact body: a cap is a face on the start or end
plane, the start one where a profile point lies on it; a wall comes from each
segment whose middle lies on its edge in the sketch plane, or whose point
half-way along lies on the face (solidcore-ops `Body::face_distances`, the
kernel's trimmed face closest point). Edges and faces SMLib built to its own
tolerance are matched to it. The smlib_sweeps roles line leaves
xtask/mesh-decisions.txt (RFC 0003, item 3.3).
2026-11-10 16:37:51 +00:00
Ben Davis 87400c80ef Threads: select a whole thread flank with one click (#519) (#11737)
* claim: #519 thread flank continuity

* Threads: keep each flank as one curved face

* Threads: preserve saved flank faces when rebuilding old designs

Keep historical thread faces and construction order when a saved feature
has no continuous_flanks field. New threads use continuous flanks by
default, with a preference for the historical layout. Redefine and dialog
reopen retain the feature's saved choice. Generic coils and hole features
keep their existing construction.

Captured pre-change flank names and points remain on the original faces,
including both chamfered ends. All 49 engine thread tests, the UI dialog
regression, and both SMLib guard checks pass. Independent Astra review
found no correctness findings. Required cargo xtask ci passed repository
validators and strict workspace Clippy; its full native test build was
stopped because the available disk cannot hold the remaining binaries.
All 14 selected crates pass cargo xtask wasm. The sweep group retains one
documented baseline cylinder-section failure.

Co-Authored-By: OpenAI Codex <noreply@openai.com>

* Threads: a new Thread dialog leaves the default flank layout out of the command

The Thread dialog put `continuous_flanks` into every new command, so the
untouched dialog no longer sent only its face and
`dialogs_thread::tests::every_option_reaches_the_command` failed. Continuous
flanks are the command's default, so the dialog now sends the option only when
the `thread_continuous_flanks` preference asks for the historical half-turn
faces. Editing a saved thread still sends its saved layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Threads: continuous flanks are not a preference; record the ladder's thread cases

New modeled threads always get continuous flank faces. The
`thread_continuous_flanks` preference, its Design page row, its message
catalog strings and its control-protocol entry are removed. A saved
thread still keeps its own `continuous_flanks` field, so an old design
reopens and rebuilds with its half-turn faces, and editing it keeps them.
The dialog test now checks that a new Thread dialog leaves the field out
and that reopening a saved thread keeps its layout.

Kernel ladder: 29 cases make fewer faces, edges and vertices because each
flank is one face: 06-threads/001 to 017 and 019 to 028, and
11-cumulative-parts/003 and 004. Their volume and area match the old
golden to 4e-9 relative and every check passes. Run with
`continuous_flanks: false`, all 29 make exactly the old counts. Only those
29 entries change; the ladder's order and report stay as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Benjamin Davis <benjamindavis@Benjamins-Mac-mini.local>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:16:55 +00:00
austin62jake d4e8c0b034 sweep: refuse a pipe whose radius reaches past a bend's centre (part of #13766) (#14640)
* sweep: test that a pipe reaching past a bend's centre is refused (#13766)

A pipe whose radius reaches the centre of a bend in its path folds its wall
through itself on the inside of that bend. The test sweeps pipes along an
ellipse with semi-axes 2 and 4 (tightest bend radius 1) and a circle of
radius 2, and expects radii 1% or more past the bend refused as InvalidInput
and radii 1% within it built as manifold solids.

On main 3e2cc277e it fails at radius 1.01: create_pipe_sweep builds the pipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* sweep: refuse a pipe whose radius reaches past a bend's centre (#13766)

create_pipe_sweep checked only that the radius is above zero, as upstream
does (SmApiBrep.cpp:1385). Where the path bends with a radius smaller than
the pipe's, the circles of neighbouring sections cross on the inside of the
bend and the swept wall folds through itself; the pipe surface is singular
wherever the radius reaches 1/kappa of the path (Maekawa, Patrikalakis,
Sakkalis and Yu, "Analysis and applications of pipe surfaces", CAGD 15(5),
1998). The panic fuzz's face_ops case 3447330831849305406 built such a
pipe (radius 5.05 along an ellipse whose tightest bend is 1.62), and its
silhouette ran past the hang limit.

path_bends_within measures the path's curvature at 16 samples per knot span,
both span ends included, and create_pipe_sweep refuses the pipe as
InvalidInput when a sample's bend radius is at most the pipe's. A refusal is
proved by the sample; a bend between two samples is missed and that pipe is
built as before. The upstream defect is recorded as WR-774.

general_path_sweep's ledger test swept its 0.75 pipe along the balance
wire's points fitted closed, which bends with radius 0.041 near its start
and 0.045 near its end. Fitted open, as the wire runs, the spline still
bends with radius 0.564 near its far end, so that case's pipe is now 0.5.
Its walls are bare B-spline faces as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:16:19 +00:00
yoyomaimai 595bc8069c Update zh-TW.json (#14641)
I've updated it:
"Counterbore Ø":
"Ø " was mistakenly typed as "O" by me.
2026-11-10 16:08:35 +00:00
PapaEcho 44fe6a0f24 Navigation: zoom horizontally with Maya Alt-right drag (#10973) (#10992)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:03:43 +00:00
dudedagger42 12a9a7f0f9 Preferences: turn the control channel on and off at once, with a port (#5736) (#13981)
* Preferences: turn the control channel on and off at once, with a port (#5736)

"Let AI agents connect" moves to Preferences > Assistant > Agents through
MCP and gets a Port field (default 27878, as before). Turning it on or
off, or changing the port, now takes effect without a restart: the
listener closes with its connections, token file and discovery file, and
opens again where the setting asks. A port another program holds shows a
message under the field. --control / SOLIDCRAFT_CONTROL_PORT still win for
the run; the setting then waits for the next launch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Preferences: "Let AI agents connect" is off by default

A new install no longer opens the control channel at launch: the switch
in Preferences > Assistant > Agents through MCP starts off, and turning it
on opens the channel at once, on its port. Preferences saved with it on
keep it on; --control and SOLIDCRAFT_CONTROL_PORT still open it for a run.
The docs and tests follow the new default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: dudedagger42 <dudedagger42@users.noreply.github.com>
2026-11-10 15:59:46 +00:00
bherbruck 3749c14ad1 sweep: a loft guided by rails, its guided edges certified within the caller's tolerance (tangency-labs/solidcore#3140) (#14204)
Rebased onto main after tangency-labs/solidcraft#13688 merged: its own change from 13688's last head (bdcebb49), three-way per file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:36:50 +00:00
rugbymauri 9c4970d2b2 Sketch: constraint glyphs can be made larger and their hover label stays clear of the cursor (#10794) (#11032)
* claim: #10794

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Sketch: constraint glyphs can be drawn larger, and their label stays clear of the cursor

Constraint glyphs are a fixed 15-pixel badge with a small letter or
icon, so zooming never made them easier to read, and the label that
names a hovered glyph sat 12 px below and right of the pointer, where
the cursor arrow covers it.

Preferences > Sketch gets "Constraint glyph size" (constraint_glyph_scale,
100% to 300%): badge, letter, icon, spacing and the hover reach grow
together. The default of 100% is the size they always had. The hover
label now goes above the glyph and the pointer, or to their left near
the top of the view, and only below them past the cursor's reach when
neither fits.

Fixes #10794

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-10 15:36:10 +00:00
bherbruck 8d13350c30 issue_3093: require the defeated body's volume to be proved positive (#14623)
volume_of() accepted Positive, Negative and Undecided enclosures alike, so a sign failure would pass. positive() panics unless soundness proves the volume Positive; the overlap and containment asserts are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:20:55 +00:00
bherbruck 995b80a78f sweep: a cornered sweep built piece by piece, meeting at its corners' mitres (#3191) (#14621)
sweep::cornered::cornered_sweep sweeps a closed planar profile along a path
with corners. Each piece is swept on its own (lines as degree-1 skins between
shared sections, arcs by the rotational sweep) and sewn as sweep::segments
sews; a corner's mitre section is asked once and shared by the runs it joins.
Twist, rails, helices, an arc with an end scale or ending at a corner, and a
closed path with an end scale or closing at a corner are refused by name.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:20:52 +00:00
ancient_astronaut_theorist 7d271b3252 Move: Ctrl/Shift+click adds more bodies after Move has gone on to From, Point or Axis (#14205) (#14218)
* claim: #14205

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr84VuQWHKtTwJ7cC3CP3W

* Move: Ctrl/Shift+click adds bodies after the dialog goes on (#14205)

In Point to Point, Point to Position and Rotate, a body pick moves the
dialog on to From, Point or Axis (#6170), so each further body needed a
click back into Bodies. A Ctrl or Shift click on a body now adds it to
Bodies (or takes it out if picked already), as Ctrl/Shift+click adds to
the selection in the view, and the dialog stays on the input it was on.
Plain clicks behave as before.

Fixes #14205

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr84VuQWHKtTwJ7cC3CP3W

* Move: add-only Shift keeps a picked body; Bodies hold while OK computes

Review follow-up for #14205. With Shift adding only (Preferences), a
Shift click on a body Move has picked already now keeps it in Bodies;
Ctrl still takes it out. While OK computes, a Ctrl or Shift click leaves
Bodies as it is, as dialog_pick holds the other inputs. The test setup
moves to a helper shared by the three tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr84VuQWHKtTwJ7cC3CP3W

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:19:06 +00:00
rugbymauri a3dcc9ca55 Agent chat: Ctrl+A selects the whole conversation, not one Markdown block (#10676) (#11033)
* claim: #10676

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Agent chat: Ctrl+A in the transcript selects the whole conversation

Each Markdown block of a reply is a label of its own, and egui's label
selection cannot be set from code, so Ctrl+A only selected the block the
selection was in. While the chat owns the keyboard and no text box has
focus, Ctrl+A (Cmd+A) now selects every prompt and answer of the retained
conversation: their text is drawn over the selection colour, and Copy
puts them on the clipboard in order, as they read, each under who wrote
it. Thoughts, tool cards, plans, notices and the composer are left out.
Esc or a click ends it; a focused composer keeps its own Select All.
Docked, floating and popped out.

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Agent chat: outline the select-all highlight so it shows on a prompt bubble

The selection fill is the same colour as the user bubble, so in the dark
theme a selected prompt looked unselected. Each highlighted block now also
gets a 1 px outline in the selection stroke colour.

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-10 15:16:31 +00:00
jyviko 2874e782fe UI kit: the app bar and built-in themes work under another host's name and tabs (#14612)
* App bar: Home follows the app's name as drawn

Home sat at a fixed 128 pt from the bar's edge, sized for "SolidCraft", so
a host with a longer name drew it under Home. The bar now lays the name
out in the font it is drawn in (semibold at the heading size) and places
Home a 6 pt gap after it, with 128 as the least place.

128 came from SolidCraft's name: it starts at 36 and is 65.1 pt wide in
Plex Sans semibold 14, ending at 101.1; Home's button starts at 108, 6.9
after it. The gap is rounded down to 6 so SolidCraft's bar does not move
(36 + 65.1 + 6 + 20 = 127.1 < 128).

Tests: app_bar::tests::home_follows_a_long_app_name (a long name's text
ends before Home; on the old code it ended at 249 with Home at 108) and
titlebar::tests::home_keeps_its_place_after_solidcrafts_name (SolidCraft's
Home stays 88 pt right of the name's start, as before). No expected
values changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8

* Theme files: built-in themes load under any host's tab table

A theme's `tabs` names tabs of the host's tab table, and reading one that
named a tab the table lacked failed the whole file. The look's built-in
themes key Onyx's per-tab surfaces by SolidCraft's tab names, and one
failed built-in empties the list, so under another host's tab table
every built-in theme but Light and Dark was missing.

Design: the strictness now depends on who wrote the file. Built-ins
(read_builtin) still check every `tabs` entry's surfaces and colours, but
leave out an entry for a tab the host lacks. Files a user imports, saves
or loads from a profile (from_value, parse, set_tabs, resolve_for_tab)
are read exactly as before, so SolidCraft's errors for a misspelt or
extra tab are unchanged and saved themes load as before.

Keying the tints by accent ramp instead would let another host's tabs
pick up Onyx's surfaces, but it changes the theme file format and needs
migration of saved and imported files; this change needs neither. Its
cost: a host whose tab names differ gets no per-tab surfaces from Onyx.

Tests: theme_files::tests::a_builtin_keeps_only_the_hosts_tabs_and_a_
users_file_is_still_refused (old code: "theme tinted: `tabs` has too many
entries") and look's every_builtin_theme_loads_under_a_host_with_other_tabs
(old code: read_builtin failed). The look tests' host tab table changes
from SolidCraft's seven tabs, which only let the built-ins load, to three
tabs it does not have (DRAW, SHAPE, PAINT); install_gives_the_kit_...
asserted 25 built-ins against it and failed (0) on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-11-10 15:07:47 +00:00
bherbruck d8113d096c STEP reader: move SolidCraft's reader into solidcore with its history (RFC 0003, 1.5) (#13979)
* Move geom's pcurve, label and check into solidcore::eval (pure git mv)

crates/geom/src/brep/{pcurve,pcurve_tests,label,check}.rs move unchanged to
solidcore/crates/solidcore/src/eval/. The next commits retype them to the kernel's evaluation
types and switch their callers; this commit is the move alone so their history follows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval: pcurve, label and check on the kernel's evaluation types

The moved files take eval::geometry's Curve, Curve2, Surface and Frame (the record's kinds,
field for field) and the kernel's evaluator, in place of the record's types and
solidcraft-geom's evaluator. What reads a record as a record leaves: the structural checks
(counts, budgets, a use's interval in its curve's natural range), the edge and face readers
(edge_curve_interval, form_turns on a Face) and the trim request types go to solidcore-ops,
which converts. Results are unchanged: the pcurve keeps its own pole test (the evaluator's
speed rule), knot span search and B-spline cut, not the kernel's collapse; the 80 eval tests
pass, the pcurve suite among them.

Lint-driven rewrites that keep each result: checked indexing, no pre-reserving from input counts,
the loop rotation without a remainder, a clamp that cannot panic on a reversed domain. The
hostile-record cases that depend on the record's checks are kept as no-panic checks here and as
refusals in ops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-ops: pcurve, label and record_check over the record

The record's side of the moved kernel routines: each checks the record's structure as the
record's validation does (one budget per face, a use's interval in its curve's natural range, a
given pcurve as a 2D B-spline), converts through solidcore-convert, calls solidcore::eval and
converts back. The trim request types (TrimFace, TrimEdge, TrimUse, Trims) hold records, so they
live here. No arithmetic: ops' float-arithmetic and geometry-method lints pass unchanged.

The hostile-record refusals the record checks give (empty and non-finite intervals, a NaN
coordinate, a circle of no radius) are tested here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Call pcurve, label and the record checks through solidcore-ops

geom, kernel, smlib and step call solidcore_ops::{pcurve, label, record_check} where they called
solidcraft_geom::brep::{pcurve, label, check}, which moved into solidcore::eval. One
implementation of each remains, in the kernel; geom's brep module no longer declares them.
solidcore-ops becomes a workspace dependency of the four crates (the seam check's public list
already allows it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move geom's record agreement check into solidcore::eval (pure git mv)

crates/geom/src/brep/geometry.rs moves unchanged to solidcore/crates/solidcore/src/eval/agreement.rs
(the kernel's eval already has a geometry module). The next commit retypes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval::agreement on a plain view; ops reads a record into it

The moved check reads a record through agreement::View (edges, vertices, faces and their uses,
in the kernel's evaluation types) in place of the record's Brep, which the kernel never sees.
solidcore-ops' record_check::agreement converts a record's curves, surfaces and pcurves into the
view and calls it; Mismatch, Problem and Record keep their wording. The pole test is the
pcurve's speed rule, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check record agreement through solidcore-ops

kernel, smlib, step and geom's tests call solidcore_ops::record_check::agreement where they
called solidcraft_geom::brep::geometry::check, which moved into solidcore::eval::agreement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave kernel/src/tess on geom's pcurve path until the tess move

crates/kernel/src/tess is moving into solidcore-tess in its own pull request, so its three
pcurve::eval call sites are not edited here: geom::brep::pcurve re-exports
solidcore_ops::pcurve::eval for them, and goes when the tess move switches them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore/bench/step: the STEP reader bake-off harness

fetch.sh builds the corpus at run time from pinned sources (SolidCraft's STEP tests,
dan335/step-conformance, solidcore's fixtures, cadmpeg's fixtures, the NIST PMI and STEP Tools
sets through their SHA-256 pinned fetch scripts), folded by SHA-256; nothing downloaded is
committed. run.py runs readers in the solidcraft-cli eval shape with a timeout and writes
per-file.tsv and summary.md: opened, refused, timed out, all bodies closed, reference volumes
within 0.1% (references.tsv, from formulas), and wall time.

On main cc55f59bf2: 319 files, 195 opened, 135 with every body closed, 23 of 24 reference
volumes, median 0.20 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move SolidCraft's STEP reader into solidcore (pure git mv)

crates/step moves unchanged to solidcore/crates/solidcore-step, and its Part 21 parser to
solidcore-record/src/step/p21.rs, where it takes the place of record's earlier port of this same
file (from a5b71ca2). This commit is the move alone, so the files keep their history and
authors; the next commits make them build in their new places and switch SolidCraft to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-record: the Part 21 parser keeps record's hardening

The moved parser takes back what record's port added in review (#2591): typed errors with the
same messages, entities in id order (BTreeMap), capped parameter and record counts, linear-time
decoding of unterminated escapes, a byte order mark skipped. The file's content is record's as
before the move; its history now runs through SolidCraft's parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: the moved reader as a solidcore workspace crate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: evaluate and form records through the kernel

The moved reader called solidcraft-geom's record evaluator, exact forms and vector helpers. It
now calls the kernel's: solidcore::eval's CurveEval, SurfaceEval, face_flux and form, through a
private `kernel` module that converts records with solidcore-convert, and the kernel's vector
measures on the record's Vec3 (`vec`). geom's form was a copy of the kernel's, function for
function; the face box (`extent`) is the kernel's with poles taken exactly. The B-spline cut is
the kernel's (eval::pcurve::segment), the pole test the pcurve's speed rule, both made public
for this. Profiling spans, which were SolidCraft's, are left out.

The crate keeps SolidCraft's formatting (its own rustfmt.toml) and lints under solidcore's hard
rules: no unsafe, unwrap, expect, panic or unchecked indexing (the 33 indexing sites are
rewritten with iterators and checked access, results unchanged). The step-conformance test reads
SolidCraft's tests/step-conformance where the tree has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* SolidCraft reads and writes STEP through solidcore-step

crates/kernel's STEP import (step_in), STEP export (step_out) and IGES export (iges_out) call
solidcore_step where they called solidcraft_step; crates/step is gone. The Part 21 parse error is
the record's typed error, shown with the same message. solidcore-step joins the crates
SolidCraft may use directly (xtask/solidcore-public.txt) until its build is kernel primitives
under an ops recipe (RFC 0003, 1.6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: bound faces with the pcurves' pole test (form::extent_by)

The bake-off showed the reader on the kernel's form::extent refusing 7 STEP Tools parts
(304-310 boxy_with_*: "loop encloses no (u, v) area") and reading the large parts 2-8x slower
(rear 37.8 s against 18.4 s): extent's exact collapse test classifies the boundary points
differently from the speed rule the reader's faces were always bounded with, and costs a
collapse search per point (the cost in #13655). extent's body now takes the pole test
(form::extent_by; extent keeps the exact one), and the reader passes the pcurves' speed rule.
On those files the result and time are base's again: boxy_with_cylindricity 174839.178,
nist_ftc_06 3323556.516, rear 14.0 s against 16.3 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Kernel rig: drop the gone crates/step paths

crates/step moved to solidcore/crates/solidcore-step, which the shared core (PATHS-ALL:
solidcore/**) already covers: the import and crash areas' PATHS and kernel-rig.yml drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cargo.lock: solidcraft-step is now solidcore-step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io: the model cache key no longer names crates/step

The reader moved to solidcore/crates/solidcore-step, which the cache already keys as part of the solidcore tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: a whole turn a rounding short of a quarter line has its form from it (#12760)

Port of #13535's fix in crates/geom's form to the kernel's form, which the STEP reader now uses: a cylinder, cone, sphere or torus face of one whole turn whose seam reads back a rounding short of a quarter line started its form a quarter early, and a tube read back through STEP measured 14 % over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* pcurve docs: a pcurve without a closed form is refused, not fitted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench/step: a malformed --reader is an error; a reader's bad JSON is a refusal, not a crash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:07:44 +00:00
bherbruck 023b55277f Booleans skip the curve intersection of straight edges whose boxes are apart: same results, joins of prisms about 1.5× faster (#14329) (#14585)
* solidcore: a boolean skips curve intersections of proto edges whose boxes are apart (#14329)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: a boolean skips curve intersections of proto edges whose boxes are apart (#14329)

A boolean splits its proto edges where they cross by intersecting every pair
of them. Two lines go to IntersectWithLine, whose first test answers "no
solution" when the segments' boxes (one grown by the tolerance) are apart.
That test now runs before the intersection's setup, through the same helper,
so the pairs it rules out cost a box test instead of an intersection. With
two coincident outlines of thousands of lines nearly every pair is such a
pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: clippy for the proto edge box test, and the counts it saves in its test (#14329)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:59:48 +00:00
0rdnungsamt 3e2cc277ee About: the licences under Licences are indented so they read as its contents (#14442)
* claim: #14388 (indent the licences under Licences in About)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* About: indent the licences under Licences so they read as its contents

NOTICE, MIT, Apache 2.0 and Third-party licences sat at the same indent as
Licences, so after opening it they looked like more sections beside it.
They are now indented by the theme's 14 pt spacing, as the Model
Parameters groups are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:48:01 +00:00
PapaEcho 5f46248771 Construct: group tools by planes, axes and points (#10149) (#10236)
* claim: #10149

* fix: group Construct tools by entity type (#10149)

* fix: make Construct menu grouping opt-in
2026-11-10 14:45:36 +00:00
SS-EN 176ba5464f Engine: selection.get reads the selection, resolved (read-only) (#3760)
An agent driving the app through MCP asked for selection.get and got
"unknown command": the selection was only reachable inside the large
document.inspect output (raw) or the UI-only ui.selection control method.

selection.get returns {count, items}: each item as select.set takes it
(so it can be fed back), with kind and, unless detail: false, what it
resolves to: faces' persistent name, surface (plane normal / cylinder
axis+radius / cone / sphere / torus) and area; edges' persistent name,
curve (line/circle/arc), length, ends and centre/radius; bodies' volume,
creating feature and visibility; component and occurrence path in an
assembly; sketch entities' sketch and entity kind. It changes nothing
(no revision bump, no undo step) and the agent chat gate classes it as
read-only. Documented in docs/control-protocol.md; tests sit with the
other selection commands' in cmd/edit_tests.rs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:38:08 +00:00
383 changed files with 16126 additions and 12587 deletions
-2
View File
@@ -30,7 +30,6 @@ on:
- "crates/kernel/**"
- "crates/geom/**"
- "crates/smlib/**"
- "crates/step/**"
- "crates/io/src/**"
- "crates/doc/src/**"
- "crates/engine/src/**"
@@ -51,7 +50,6 @@ on:
- "crates/kernel/**"
- "crates/geom/**"
- "crates/smlib/**"
- "crates/step/**"
- "crates/io/src/**"
- "crates/doc/src/**"
- "crates/engine/src/**"
Generated
+11 -11
View File
@@ -4234,6 +4234,16 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "solidcore-step"
version = "0.1.0"
dependencies = [
"solidcore",
"solidcore-convert",
"solidcore-ops",
"solidcore-record",
]
[[package]]
name = "solidcore-tess"
version = "0.1.0"
@@ -4478,11 +4488,11 @@ dependencies = [
"serde_json",
"solidcore-ops",
"solidcore-record",
"solidcore-step",
"solidcore-tess",
"solidcraft-geom",
"solidcraft-profile",
"solidcraft-smlib",
"solidcraft-step",
"spade",
"thiserror 2.0.21",
"web-time",
@@ -4587,16 +4597,6 @@ dependencies = [
"serde",
]
[[package]]
name = "solidcraft-step"
version = "0.40.0-rc.3"
dependencies = [
"solidcore-ops",
"solidcore-record",
"solidcraft-geom",
"solidcraft-profile",
]
[[package]]
name = "solidcraft-testkit"
version = "0.40.0-rc.3"
+2 -1
View File
@@ -21,7 +21,6 @@ solidcraft-profile = { path = "crates/profile" }
solidcraft-sketch = { path = "crates/sketch" }
solidcraft-kernel = { path = "crates/kernel" }
solidcraft-smlib = { path = "crates/smlib" }
solidcraft-step = { path = "crates/step" }
solidcraft-render = { path = "crates/render" }
solidcraft-dynamics = { path = "crates/dynamics" }
solidcraft-gpu = { path = "crates/gpu" }
@@ -55,6 +54,8 @@ solidcore-record = { path = "solidcore/crates/solidcore-record" }
# The modelling layer (solidcore's layers §1, "Ops"): the record-level calls SolidCraft makes into the
# kernel, converting through solidcore-convert.
solidcore-ops = { path = "solidcore/crates/solidcore-ops" }
# STEP import and export (vendored with solidcore): the Part 21 parser is solidcore-record's.
solidcore-step = { path = "solidcore/crates/solidcore-step" }
# Geometry made drawable (solidcore's layers §3): the one mesher, SolidCraft's, moved into the
# subtree (RFC 0003, item 1.1), with the display mesh and its levels of detail, the types
# SolidCraft's meshes are. Vendored with solidcore, in the same subtree.
+10 -6
View File
@@ -148,14 +148,18 @@ self.addEventListener("activate", (event) => {
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
return self.registration.unregister();
}
for (const name of await caches.keys()) {
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
}
const older = (await caches.keys()).filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE);
for (const name of older) await (await caches.open(name)).put(url(RETIRED), new Response(""));
await sweep();
await self.clients.claim();
// Pages open when the worker first takes over came from the network, and get their files from it.
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
// With no earlier build kept, the pages open when the first worker takes over came from the
// network, and get their files from it. After an update a page with no record is not
// assumed to be anything: its record may only be late, and the network now serves a newer
// build than the page's.
if (!older.length) {
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
}
}
})(),
);
+63 -35
View File
@@ -3,7 +3,7 @@
//! with what SolidCraft's requests mean ([`SolidCraft`]).
use std::net::TcpListener;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::mpsc::{Sender, channel};
use std::sync::{Arc, OnceLock};
use serde_json::{Value, json};
@@ -12,6 +12,7 @@ use solidcraft_control::{ControlRequest, auth};
use solidcraft_engine::MAX_LINE;
use solidcraft_engine::control_auth::APP;
use solidcraft_engine::control_pending;
use solidcraft_ui_egui::control_switch;
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
/// token in each, removed when the app ends unless another launch has written the file since.
@@ -85,40 +86,60 @@ pub fn remove_token_file() {
/// The port the app's own channel listens on and its token, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<(u16, String)>> = std::sync::Mutex::new(None);
pub fn start(listen_on: auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
/// Open the app's control channel (at launch, or turned on in the settings): its listener, its
/// token file and the discovery file. Stopping it removes both files again.
pub fn start(listen_on: auth::Listen, ctx: egui::Context) -> Result<control_switch::Started, control_switch::StartError> {
use control_switch::StartError;
let (tx, rx) = channel::<ControlRequest>();
// Every connection must show the token before anything runs (docs/control-protocol.md).
match APP.env_token().or_else(server::new_token) {
Some(token) => {
let listener = match listen_on {
auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
auth::Listen::Default => auth::bind_with_fallback(APP.default_port, auth::PORT_TRIES),
};
match listener {
Ok(l) => {
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
if matches!(listen_on, auth::Listen::Default) {
solidcraft_engine::note!(
"solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)"
);
}
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
match APP.write_current_port(port) {
Ok(_) => {
if let Ok(mut p) = APP_PORT.lock() {
*p = Some((port, token));
}
}
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
}
}
}
Err(e) => solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
let Some(token) = APP.env_token().or_else(server::new_token) else {
solidcraft_engine::note!("solidcraft: control server not started: no random token available");
return Err(StartError::Failed("no random token available".into()));
};
let listener = match listen_on {
auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
auth::Listen::Default => auth::bind_with_fallback(APP.default_port, auth::PORT_TRIES),
};
let listener = listener.map_err(|e| {
solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}");
match (listen_on, e.kind()) {
// In use; Windows reports a port another program holds exclusively as denied.
(auth::Listen::Exact(port), std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied) => StartError::PortTaken(port),
(auth::Listen::Default, std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied) => StartError::PortTaken(APP.default_port),
_ => StartError::Failed(e.to_string()),
}
})?;
let (port, stop) =
listen_on_listener(listener, token.clone(), tx, ctx, false).ok_or_else(|| StartError::Failed("the listener did not start".into()))?;
if matches!(listen_on, auth::Listen::Default) {
solidcraft_engine::note!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
}
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
match APP.write_current_port(port) {
Ok(_) => {
if let Ok(mut p) = APP_PORT.lock() {
*p = Some((port, token.clone()));
}
}
None => solidcraft_engine::note!("solidcraft: control server not started: no random token available"),
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
}
rx
let stop = Box::new(move || {
stop.stop();
if let Ok(mut p) = APP_PORT.lock()
&& p.as_ref().is_some_and(|(held, _)| *held == port)
{
*p = None;
}
APP.remove_current_port(port, &token);
if let Some(path) = APP.token_path(port) {
auth::remove_token_file(&path, &token);
if let Ok(mut files) = TOKEN_FILES.lock() {
files.retain(|(p, _)| *p != path);
}
}
solidcraft_engine::note!("solidcraft: control channel on 127.0.0.1:{port} closed");
});
Ok(control_switch::Started { port, requests: rx, stop })
}
/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback
@@ -137,12 +158,19 @@ pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u1
return None;
}
};
listen_on_listener(listener, token, tx, ctx, true)
// The chat's endpoint lives as long as the app.
listen_on_listener(listener, token, tx, ctx, true).map(|(port, _)| port)
}
/// Serve `listener` (loopback) with `token`; the port it listens on.
/// Serve `listener` (loopback) with `token`; the port it listens on and how to stop it.
/// `patient`: every request may wait as long as a batch (a person decides on it).
fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
fn listen_on_listener(
listener: TcpListener,
token: String,
tx: Sender<ControlRequest>,
ctx: egui::Context,
patient: bool,
) -> Option<(u16, server::Stop)> {
let port = listener.local_addr().ok()?.port();
match APP.token_path(port) {
Some(path) => match APP.write_token_file(&path, &token) {
@@ -164,8 +192,8 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
)
}
}
match hub(ctx).listen(listener, token, tx, patient) {
Ok(()) => Some(port),
match hub(ctx).listen_stoppable(listener, token, tx, patient) {
Ok(stop) => Some((port, stop)),
Err(e) => {
solidcraft_engine::note!("solidcraft: control server not started: {e}");
None
+5 -6
View File
@@ -1320,12 +1320,11 @@ fn main() -> eframe::Result {
));
}
mark("viewport pipelines");
// `--control`/the environment name a port; otherwise the preference (on by default)
// opens the default one (docs/control-protocol.md).
if let Some(listen_on) = solidcraft_control::auth::plan(control_port, app.preferences.agent_control) {
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
app = app.with_control(rx);
}
// `--control`/the environment name a port for this run; otherwise the preference (off
// by default) opens its port, and turning it off or on later takes effect at once
// (docs/control-protocol.md).
let ctx = cc.egui_ctx.clone();
solidcraft_ui_egui::control_switch::launch(&mut app, Box::new(move |listen| control_server::start(listen, ctx.clone())), control_port);
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
// on a free port the first time the chat connects (docs/agent-chat.md).
let ctx = cc.egui_ctx.clone();
+41 -41
View File
@@ -931,7 +931,7 @@
"ai.http.confirm": "السماح لهذه النقطة الطرفية",
"ai.http.warning": "سيُرسَل مفتاح API والمحادثة دون تشفير. يمكن لأي شخص لديه وصول إلى الشبكة قراءتهما أو تغييرهما. استخدم هذا الخيار فقط على شبكة تثق بها.",
"ai.prefs.allow_control": "السماح لوكلاء الذكاء الاصطناعي بالاتصال",
"ai.prefs.allow_control_note": "قناة محلية (MCP). لا شيء يغادر هذا الحاسوب.\n\nيفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، المنفذ 27878 أو أول منفذ متاح بعده). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يُطبّق في المرة القادمة التي يبدأ فيها SolidCraft. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
"ai.prefs.allow_control_note": "قناة محلية (MCP). لا شيء يغادر هذا الحاسوب.\n\nيفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، على المنفذ أدناه؛ وإذا كان 27878 مستخدمًا فيُستخدم المنفذ الحر التالي). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يسري فورًا. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
"ai.prefs.availability": "التوفر",
"ai.prefs.background_commands": "تشغيل الأوامر البطيئة في الخلفية",
"ai.prefs.connect_apps": "إضافة SolidCraft إلى تطبيقات الذكاء الاصطناعي",
@@ -1890,13 +1890,13 @@
"help.sketch.construction_line": "أنشئ خطًا إنشائيًا لا نهائيًا يمر بنقطتين. استخدمه لتخطيط الرسم دون تقسيم مقاطعه.",
"help.sketch.construction_line.angle": "أنشئ خطًا إنشائيًا لا نهائيًا بزاوية من محور X للرسم. اختر النقطة ثم اكتب الزاوية، أو اختر اتجاهه.",
"help.sketch.construction_line.bisector": "اختر خطين مستقيمين متقاطعين في الرسم، ثم اختر أحد منصفي الزاوية. يتبع الناتج الخطين اللذين أُنشئ منهما.",
"help.sketch.construction_line.horizontal": "أنشئ خطًا إنشائيًا أفقيًا لا نهائيًا يمر بنقطة. يحافظ القيد الأفقي على الاتجاه بعد التعديلات.",
"help.sketch.construction_line.parallel": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا موازيًا له. انقر بزر الفأرة الأيمن لتغيير المرجع.",
"help.sketch.construction_line.horizontal": "أنشئ خطًا إنشائيًا أفقيًا لا نهائيًا يمر بنقطة، أو أدخل بعده عن المحور X للرسم. يصبح البعد المُدخل بُعدًا قائدًا. يحافظ القيد الأفقي على الاتجاه بعد التعديلات.",
"help.sketch.construction_line.parallel": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا موازيًا له، أو أدخل إزاحته. تصبح الإزاحة المُدخلة مسافة قائدة. انقر بزر الفأرة الأيمن لتغيير المرجع.",
"help.sketch.construction_line.perpendicular": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا عموديًا عليه. انقر بزر الفأرة الأيمن لتغيير المرجع.",
"help.sketch.construction_line.tangent_at": "أنشئ مماسًا عند نقطة على دائرة أو قوس دائري، أو عند نهاية منحنى شريحي مفتوح أو قطع مخروطي مفتوح. اختر المنحنى ثم نقطة التماس.",
"help.sketch.construction_line.tangent_external": "اختر دائرة أو قوسًا دائريًا، ثم نقطة خارجية وحل المماس. تعرض المعاينة نقاط التماس الواقعة على القوس المرسوم فقط.",
"help.sketch.construction_line.tangent_two": "اختر دائرتين أو قوسين دائريين ثم اختر مماسًا مشتركًا من المعاينة. يجب أن تقع نقطتا التماس على المنحنيين المرسومين.",
"help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.",
"help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة، أو أدخل بعده عن المحور Y للرسم. يصبح البعد المُدخل بُعدًا قائدًا. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.",
"help.sketch.copy": "ينسخ منحنيات الرسم ونقاطه المحددة مع القيود والأبعاد بينها. الصقها في هذا الرسم أو في رسم آخر.",
"help.sketch.create": "يبدأ رسمًا ثنائي الأبعاد جديدًا على مستوى أو على وجه مسطح من النموذج. يبدأ كل بثق ودوران وكنس برسم.",
"help.sketch.cut": "ينسخ هندسة الرسم المحددة ويحذفها من الرسم بخطوة تراجع واحدة. الصقها في مكان آخر من هذا الرسم أو في رسم آخر.",
@@ -3224,10 +3224,10 @@
"render.category_paint": "طلاء",
"render.category_plastic": "بلاستيك",
"render.category_wood": "خشب",
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
"render.caustics_area_lights": "تجمّع الضوء من الأضواء المساحية",
"render.caustics_depth": "عمق مسار تجمّع الضوء",
"render.caustics_enable": "تجمّع الضوء عبر الزجاج من مصادر نقطية واتجاهية",
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
"render.caustics_environment": "تجمّع الضوء من البيئة",
"render.caustics_help": "يتحكم عدد الفوتونات في الضجيج وكلفة الحساب. يوازن نصف قطر التجميع بين التفاصيل الدقيقة والتقدير الأنعم. يتطلب CPU إضاءة غير مباشرة.",
"render.caustics_path_required": "يتطلب تجمّع الضوء على CPU إضاءة غير مباشرة.",
"render.caustics_photons": "عدد الفوتونات",
@@ -3235,7 +3235,7 @@
"render.check_glass_cpu": "يدعم CPU صفائح زجاج مستوية وصناديق محاذية للمحاور بعد التحقق منها. تتطلب الشبكات المغلقة العامة والتعشيق والتشتت الداخلي إضاءة غير مباشرة. لا تزال الظلال النافذة عبر الزجاج المغلق غير مدعومة.",
"render.check_glass_gpu": "يدعم GPU أوساط زجاج منفصلة متحققًا منها، أو تعشيقًا متحققًا منه عند تفعيله صراحةً. يتطلب التعشيق أغلفة خارجية محدبة وأجسامًا داخلية محتواة بالكامل وحدودًا غير متلامسة.",
"render.check_media_remaining": "لا تزال شبكات الكثافة والإزاحة والتكامل الطيفي الكامل ونقل حالة الاستقطاب غير متاحة.",
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة الضجيج. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة ضجيج الأسطح.",
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة التشويش. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة تشويش الأسطح.",
"render.check_opaque": "الشفافية: أسطح معتمة فقط؛ ألفا الخلفية مدعومة.",
"render.check_sorted_alpha": "الشفافية: تقريب ألفا مرتّب.",
"render.check_transmission": "الشفافية: نفاذية فيزيائية.",
@@ -3441,8 +3441,8 @@
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلًا، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعًا آخر أو GPU.",
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة التشويش. تبقى إعداداتك محفوظة.",
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة التشويش. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
"render.cpu_threads": "خيوط المعالجة",
"render.cpu_threads_auto": "تلقائي",
@@ -3458,40 +3458,40 @@
"render.cpu_volume_method_incompatible": "لا يمكن نقل إعدادات الإضاءة أو أخذ العينات أو الغلاف الجوي هذه إلى هذه الطريقة. تبقى إعداداتك محفوظة.",
"render.cpu_volume_path_required": "فعّل الإضاءة غير المباشرة لاستخدام الغلاف الجوي على CPU.",
"render.cpu_warm_cool": "دافئ وبارد",
"render.denoise_class_background": "متجاوز: خلفية",
"render.denoise_class_coverage": "متجاوز: تغطية جزئية",
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
"render.denoise_class_lens": "متجاوز: عمق المجال",
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
"render.denoise_class_nested": "متجاوز: وسط متداخل",
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
"render.denoise_class_background": "مستبعد: خلفية",
"render.denoise_class_coverage": "مستبعد: تغطية جزئية",
"render.denoise_class_degenerate": "مستبعد: مسار منحلّ",
"render.denoise_class_depth": "مستبعد: حد عمق المسار",
"render.denoise_class_emitter": "مستبعد: مصدر ضوء",
"render.denoise_class_exit": "مستبعد: خروج غير متوافق",
"render.denoise_class_identity": "مستبعد: حد هوية الدليل",
"render.denoise_class_lens": "مستبعد: عمق المجال",
"render.denoise_class_material": "مستبعد: مادة غير مدعومة",
"render.denoise_class_nested": "مستبعد: وسط متداخل",
"render.denoise_class_optical": "مؤهل: سطح مرئي عبر الانكسار",
"render.denoise_class_reflection": "مستبعد: نهاية عاكسة",
"render.denoise_class_surface": "مؤهل: سطح",
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
"render.denoise_class_tir": "مستبعد: انعكاس داخلي كلي",
"render.denoise_class_transmission": "مستبعد: نفاذ إضافي",
"render.denoise_class_unavailable": "مستبعد: بلا دليل",
"render.denoise_class_volume": "مستبعد: تشتت حجمي",
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المستبعدة: {skipped}",
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة التشويش",
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمستبعدة حتى عند إيقاف إزالة التشويش. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
"render.denoise_diagnostics_title": "تشخيصات إزالة التشويش",
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
"render.denoise_format_mask": "قناع PNG",
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
"render.denoise_optical_filter": "تصفية المرايا والزجاج",
"render.denoise_optical_filter_help": "تصفية الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة الضجيج من الأسطح المعتمة فقط.",
"render.denoise_passes": "مرات الترشيح",
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
"render.denoise_strength": "قوة إزالة الضوضاء",
"render.denoise_mask_copy_help": "صدّر قناع إزالة التشويش بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة التشويش وصيّر صورة لفحص قناعها.",
"render.denoise_optical_filter": "ترشيح المرايا والزجاج",
"render.denoise_optical_filter_help": "يرشّح الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة التشويش من الأسطح المعتمة فقط.",
"render.denoise_passes": "تمريرات الترشيح",
"render.denoise_settings_help": "تُنعّم تمريرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح الذي يطبّقه كل تمرير. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
"render.denoise_strength": "قوة إزالة التشويش",
"render.denoise_view_image": "الصورة المصيّرة",
"render.denoise_view_mask": "قناع إزالة الضوضاء",
"render.denoise_view_mask": "قناع إزالة التشويش",
"render.diag_bodies": "الأجسام المرئية",
"render.diag_job_details": "تفاصيل المهمة الملتقطة",
"render.diag_last_check": "آخر فحص مسبق",
@@ -4047,7 +4047,7 @@
"render.material_v_up": "اتجاه V للنسيج يشير للأعلى في الصورة",
"render.material_weight": "وزن الطبقة (0–1)",
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
"render.metal_energy_compensation_help": "عوّض الضوء المفقود بين الأوجه المجهرية للمعادن الخشنة. يتطلب التصيير على CPU إضاءة غير مباشرة.",
"render.method_cpu": "تتبع الأشعة على CPU",
"render.method_gpu": "تتبع المسارات على GPU",
"render.method_limit_raster": "التنقيط يستخدم معاينة الاستوديو المعتمة الثابتة. اختر طريقة تتبع أشعة صريحة لعناصر التحكم البصرية المدعومة.",
@@ -4507,7 +4507,7 @@
"render.surface_import.source": "المصدر",
"render.surface_import.title": "استيراد صورة",
"render.texture_filtering": "ترشيح تفاصيل الخامات",
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً.",
"render.texture_filtering_help": "استخدم مستويات MIP والترشيح متباين الخواص لتقليل تعرّج الخامات عند التصغير. يستهلك ذاكرة أكبر؛ وعند إيقافه يبقى الترشيح السابق.",
"render.tone_aces_fitted": "ACES مُلاءَم",
"render.tone_reinhard": "راينهارد",
"render.transmission_scope": "يدعم زجاج GPU الأوساط المنفصلة المعتمدة. لا تتوفر الأوساط المتداخلة غير المقيدة ولا مقدّر نفاذ خالٍ من التشويش.",
@@ -4552,7 +4552,7 @@
"render.use_preview": "استخدام المعاينة المدمجة",
"render.volume_albedo": "بياض التشتت · RGB خطي",
"render.volume_anisotropy": "متوسط جيب تمام الطور",
"render.volume_denoise_required": "أوقف تقليل الضوضاء لاستخدام الغلاف الجوي.",
"render.volume_denoise_required": "أوقف تقليل التشويش لاستخدام الغلاف الجوي.",
"render.volume_enable": "تفعيل الوسط المشارك",
"render.volume_extinction": "معامل الإخماد",
"render.volume_glass_required": "لا يمكن الجمع بين الغلاف الجوي والزجاج في هذا الإعداد.",
@@ -5041,7 +5041,7 @@
"ui.agent_chat_permission": "يطلب الوكيل تشغيل: {title}",
"ui.agent_chat_pick": "الوكيل",
"ui.agent_chat_pick_context": "اختيار السياق",
"ui.agent_chat_pick_context_tip": "اختر أوجهًا وحوافًا وميزات وأجسامًا في النموذج ليطّلع عليها الوكيل، ثم اضغط «موافق»",
"ui.agent_chat_pick_context_tip": "اختر أوجهًا وحوافًا وميزات وأجسامًا في النموذج، ثم اضغط «موافق»: تُدرج في الرسالة كشارة في موضع المؤشر (كتابة @ تفعل الشيء نفسه)",
"ui.agent_chat_placeholder": "اطلب من الوكيل تغيير التصميم…",
"ui.agent_chat_placeholder_busy": "ضع رسالة في قائمة الانتظار لحين انتهاء الوكيل (Ctrl+Enter: أرسل الآن)",
"ui.agent_chat_pop_out": "فتح في نافذة مستقلة يمكنك نقلها إلى شاشة أخرى",
+1 -1
View File
@@ -931,7 +931,7 @@
"ai.http.confirm": "Allow for this endpoint",
"ai.http.warning": "The API key and conversation will be sent unencrypted. Anyone with access to the network may read or change them. Use this only on a network you trust.",
"ai.prefs.allow_control": "Let AI agents connect",
"ai.prefs.allow_control_note": "A local channel (MCP). Nothing leaves this computer.\n\nOpens a control channel on this computer only (127.0.0.1, port 27878 or the next free one). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect the next time SolidCraft starts. Off: nothing listens unless you start SolidCraft with --control.",
"ai.prefs.allow_control_note": "A local channel (MCP). Nothing leaves this computer.\n\nOpens a control channel on this computer only (127.0.0.1, on the port below; 27878 takes the next free one when it is in use). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect at once. Off: nothing listens unless you start SolidCraft with --control.",
"ai.prefs.availability": "Availability",
"ai.prefs.background_commands": "Run slow commands in the background",
"ai.prefs.connect_apps": "Add SolidCraft to AI apps",
+5
View File
@@ -218,6 +218,7 @@
"Expected result (optional)": "Résultat attendu (facultatif)",
"Explode": "Éclater",
"Explode Text": "Décomposer le texte",
"Export 3MF…": "Exporter 3MF…",
"Export Animation…": "Exporter l'animation…",
"Export Bend Table…": "Exporter la table des plis…",
"Export CSV…": "Exporter CSV…",
@@ -226,6 +227,10 @@
"Export DXF": "Exporter en DXF",
"Export DXF…": "Exporter en DXF…",
"Export JSON…": "Exporter JSON…",
"Export OBJ…": "Exporter OBJ…",
"Export STEP…": "Exporter STEP…",
"Export STL…": "Exporter STL…",
"Export URDF…": "Exporter URDF…",
"Export design library...": "Exporter la bibliothèque de la conception...",
"Export hidden edges as dashed lines": "Exporter les arêtes cachées en tirets",
"Exported by SolidWorks, which usually saves Y up": "Exporté par SolidWorks, qui enregistre généralement en Y vers le haut",
@@ -0,0 +1,10 @@
{
"en": { "ui.agent_chat_transcript_assistant": "Assistant:", "ui.agent_chat_transcript_user": "User:" },
"ar": { "ui.agent_chat_transcript_assistant": "المساعد:", "ui.agent_chat_transcript_user": "المستخدم:" },
"de": { "ui.agent_chat_transcript_assistant": "Assistent:", "ui.agent_chat_transcript_user": "Benutzer:" },
"he": { "ui.agent_chat_transcript_assistant": "עוזר:", "ui.agent_chat_transcript_user": "משתמש:" },
"ja": { "ui.agent_chat_transcript_assistant": "アシスタント:", "ui.agent_chat_transcript_user": "ユーザー:" },
"ko": { "ui.agent_chat_transcript_assistant": "어시스턴트:", "ui.agent_chat_transcript_user": "사용자:" },
"vi": { "ui.agent_chat_transcript_assistant": "Trợ lý:", "ui.agent_chat_transcript_user": "Người dùng:" },
"zh": { "ui.agent_chat_transcript_assistant": "助手:", "ui.agent_chat_transcript_user": "用户:" }
}
@@ -0,0 +1,10 @@
{
"en": { "ui.agent_chat_model_search_or_id": "Search, or type any model id…" },
"ar": { "ui.agent_chat_model_search_or_id": "ابحث، أو اكتب أي معرّف نموذج…" },
"de": { "ui.agent_chat_model_search_or_id": "Suchen oder eine beliebige Modell-ID eingeben…" },
"he": { "ui.agent_chat_model_search_or_id": "חיפוש, או הקלדת מזהה מודל כלשהו…" },
"ja": { "ui.agent_chat_model_search_or_id": "検索、または任意のモデル ID を入力…" },
"ko": { "ui.agent_chat_model_search_or_id": "검색하거나 모델 ID를 직접 입력…" },
"vi": { "ui.agent_chat_model_search_or_id": "Tìm, hoặc nhập mã mô hình bất kỳ…" },
"zh": { "ui.agent_chat_model_search_or_id": "搜索,或输入任意模型 ID…" }
}
@@ -12,7 +12,7 @@
},
"ar": {
"browser.straighten_angle": "اتجاه الحافة",
"browser.straighten_choose": "اختر ما تصبح عليه الحافة، أفقية أو رأسية أو بزاوية، ثم اضغط تقويم.",
"browser.straighten_choose": "اختر ما تصبح عليه الحافة، أفقية أو رأسية أو بزاوية، ثم اضغط «تقويم».",
"browser.straighten_click_a": "انقر الصورة عند أحد طرفي حافة يجب أن تكون مستقيمة، أو اكتب إحداثياتها.",
"browser.straighten_click_b": "انقر الطرف الآخر للحافة، أو اكتب إحداثياته.",
"browser.straighten_horizontal": "أفقي",
@@ -0,0 +1,38 @@
{
"en": {
"prefs.constraint_glyph_size": "Constraint glyph size",
"prefs.constraint_glyph_size_note": "How large constraint glyphs and their letters or icons are drawn in a sketch. 100% is the usual size; glyphs keep this screen size at every zoom."
},
"ar": {
"prefs.constraint_glyph_size": "حجم رموز القيود",
"prefs.constraint_glyph_size_note": "حجم رسم رموز القيود وحروفها أو أيقوناتها في الرسم. 100% هو الحجم المعتاد؛ وتحتفظ الرموز بهذا الحجم على الشاشة عند أي مستوى تكبير."
},
"de": {
"prefs.constraint_glyph_size": "Größe der Bindungssymbole",
"prefs.constraint_glyph_size_note": "Wie groß Bindungssymbole samt Buchstaben oder Icons in einer Skizze gezeichnet werden. 100 % ist die übliche Größe; die Symbole behalten diese Bildschirmgröße bei jedem Zoom."
},
"fr": {
"prefs.constraint_glyph_size": "Taille des symboles de contraintes",
"prefs.constraint_glyph_size_note": "Taille à laquelle les symboles de contraintes, avec leurs lettres ou icônes, sont dessinés dans une esquisse. 100 % est la taille habituelle ; les symboles gardent cette taille à l'écran quel que soit le zoom."
},
"he": {
"prefs.constraint_glyph_size": "גודל סמלי האילוצים",
"prefs.constraint_glyph_size_note": "הגודל שבו מצוירים סמלי האילוצים, עם האותיות או הסמלונים שלהם, בשרטוט. 100% הוא הגודל הרגיל; הסמלים שומרים על גודל המסך הזה בכל זום."
},
"ja": {
"prefs.constraint_glyph_size": "拘束グリフのサイズ",
"prefs.constraint_glyph_size_note": "スケッチ内の拘束グリフとその文字やアイコンを描く大きさ。100% が通常のサイズです。グリフはどのズームでもこの画面サイズを保ちます。"
},
"ko": {
"prefs.constraint_glyph_size": "구속 조건 기호 크기",
"prefs.constraint_glyph_size_note": "스케치에서 구속 조건 기호와 그 문자나 아이콘을 그리는 크기입니다. 100%가 기본 크기이며, 기호는 확대/축소와 관계없이 이 화면 크기를 유지합니다."
},
"vi": {
"prefs.constraint_glyph_size": "Kích thước biểu tượng ràng buộc",
"prefs.constraint_glyph_size_note": "Kích thước vẽ biểu tượng ràng buộc cùng chữ cái hoặc biểu tượng bên trong trong bản phác. 100% là kích thước thông thường; biểu tượng giữ kích thước màn hình này ở mọi mức thu phóng."
},
"zh": {
"prefs.constraint_glyph_size": "约束符号大小",
"prefs.constraint_glyph_size_note": "草图中约束符号及其字母或图标的绘制大小。100% 为常规大小;任意缩放比例下符号都保持此屏幕大小。"
}
}
@@ -0,0 +1,58 @@
{
"en": {
"ui.construct_planes": "Planes",
"ui.construct_axes": "Axes",
"ui.construct_points": "Points",
"prefs.construct_menu_groups": "Group Construct tools by entity type",
"prefs.construct_menu_groups_help": "Show Planes, Axes and Points headings. Off keeps the original menu order."
},
"ar": {
"ui.construct_planes": "المستويات",
"ui.construct_axes": "المحاور",
"ui.construct_points": "النقاط",
"prefs.construct_menu_groups": "تجميع أدوات الإنشاء حسب نوع العنصر",
"prefs.construct_menu_groups_help": "إظهار عناوين المستويات والمحاور والنقاط. يحافظ التعطيل على ترتيب القائمة الأصلي."
},
"he": {
"ui.construct_planes": "מישורים",
"ui.construct_axes": "צירים",
"ui.construct_points": "נקודות",
"prefs.construct_menu_groups": "קיבוץ כלי הבנייה לפי סוג ישות",
"prefs.construct_menu_groups_help": "הצגת כותרות מישורים, צירים ונקודות. כיבוי שומר על סדר התפריט המקורי."
},
"ja": {
"ui.construct_planes": "平面",
"ui.construct_axes": "軸",
"ui.construct_points": "点",
"prefs.construct_menu_groups": "構築ツールを要素の種類でグループ化",
"prefs.construct_menu_groups_help": "平面、軸、点の見出しを表示します。オフにすると元のメニュー順序を維持します。"
},
"zh": {
"ui.construct_planes": "平面",
"ui.construct_axes": "轴",
"ui.construct_points": "点",
"prefs.construct_menu_groups": "按实体类型分组构造工具",
"prefs.construct_menu_groups_help": "显示平面、轴和点标题。关闭时保留原始菜单顺序。"
},
"de": {
"ui.construct_planes": "Ebenen",
"ui.construct_axes": "Achsen",
"ui.construct_points": "Punkte",
"prefs.construct_menu_groups": "Konstruktionswerkzeuge nach Elementtyp gruppieren",
"prefs.construct_menu_groups_help": "Zeigt Überschriften für Ebenen, Achsen und Punkte. Aus behält die ursprüngliche Menüreihenfolge bei."
},
"ko": {
"ui.construct_planes": "평면",
"ui.construct_axes": "축",
"ui.construct_points": "점",
"prefs.construct_menu_groups": "요소 유형별로 구성 도구 그룹화",
"prefs.construct_menu_groups_help": "평면, 축, 점 제목을 표시합니다. 끄면 원래 메뉴 순서를 유지합니다."
},
"vi": {
"ui.construct_planes": "Các mặt phẳng",
"ui.construct_axes": "Các trục",
"ui.construct_points": "Các điểm",
"prefs.construct_menu_groups": "Nhóm công cụ dựng theo loại đối tượng",
"prefs.construct_menu_groups_help": "Hiện tiêu đề Mặt phẳng, Trục và Điểm. Tắt để giữ thứ tự menu ban đầu."
}
}
@@ -13,9 +13,9 @@
"prefs.export_name": "اسم ملف التصدير",
"prefs.export_name.body": "اسم الجسم المُصدَّر (الأول إن تعددت الأجسام)",
"prefs.export_name.design": "اسم التصميم",
"prefs.export_name_hint": "مثلاً {design}-{body}",
"prefs.export_name_hint": "مثلًا {design}-{body}",
"prefs.export_name_sample": "تصدير هذا التصميم يقترح: {name}",
"prefs.export_name_tip": "اسم الملف الذي يقترحه ملف › تصدير وحفظ كشبكة. تُستبدل هذه العناصر النائبة:\n{placeholders}\nتُحذف خاصية التصميم غير المعيّنة. اترك الحقل فارغاً لاقتراح اسم التصميم، أو اسم الجسم للشبكة."
"prefs.export_name_tip": "اسم الملف الذي يقترحه «ملف › تصدير» و«حفظ كشبكة». تُستبدل هذه العناصر النائبة:\n{placeholders}\nتُحذف خاصية التصميم غير المعيّنة. اترك الحقل فارغًا لاقتراح اسم التصميم، أو اسم الجسم للشبكة."
},
"de": {
"prefs.export": "Export",
@@ -1,7 +1,7 @@
{
"ar": {
"prefs.fillet_legacy_radius": "استخدم نصف قطر Fillet الصلب الافتراضي السابق",
"prefs.fillet_legacy_radius.help": "مطفأ افتراضيًا. عند التشغيل، يفتح Fillet الصلب الجديد عند الرقم 2 مقروءًا بوحدات التصميم (\"2 بوصة\" في تصميم بالبوصة)، كما كان سابقًا؛ وعند الإيقاف، يفتح عند نصف القطر الذي تُسلَّم به الأداة، 2 مم، معروضًا بوحدات التصميم (\"0.07874 بوصة\" في تصميم بالبوصة)."
"prefs.fillet_legacy_radius": "استخدام نصف القطر الافتراضي السابق للتقويس المصمت",
"prefs.fillet_legacy_radius.help": "معطّل افتراضيًا. عند التفعيل، يفتح التقويس المصمت الجديد على الرقم 2 مقروءًا بوحدات التصميم («2 بوصة» في تصميم بالبوصة)، كما كان سابقًا؛ وعند الإيقاف، يفتح على نصف القطر الافتراضي للأداة، 2 مم، معروضًا بوحدات التصميم («0.07874 بوصة» في تصميم بالبوصة)."
},
"de": {
"prefs.fillet_legacy_radius": "Den vorherigen Standardradius für Solid-Fillet verwenden",
@@ -4,8 +4,8 @@
"ui.first_dimension_scales_about_origin_tip": "On: the drawing always grows or shrinks about the sketch origin, so a shape drawn away from the origin moves. Off: a drawing that nothing ties to the origin scales about its own middle and stays where it was drawn."
},
"ar": {
"ui.first_dimension_scales_about_origin": "تغيير المقياس حول أصل الرسم التخطيطي",
"ui.first_dimension_scales_about_origin_tip": "عند التفعيل: يكبر الرسم أو يصغر دائمًا حول أصل الرسم التخطيطي، فيتحرك الشكل المرسوم بعيدًا عن الأصل. عند الإيقاف: الرسم غير المرتبط بالأصل يتغير مقياسه حول منتصفه ويبقى في مكانه."
"ui.first_dimension_scales_about_origin": "تغيير المقياس حول أصل الرسم",
"ui.first_dimension_scales_about_origin_tip": "عند التفعيل: يكبر المرسوم أو يصغر دائمًا حول أصل الرسم، فإذا كان الشكل مرسومًا بعيدًا عن الأصل تحرّك. عند الإيقاف: المرسوم غير المرتبط بالأصل يتغير مقياسه حول منتصفه ويبقى حيث رُسم."
},
"de": {
"ui.first_dimension_scales_about_origin": "Um den Skizzenursprung skalieren",
+2 -2
View File
@@ -21,7 +21,7 @@
"prefs.graphics_api_auto": "تلقائي",
"prefs.graphics_api_help": "اختر واجهة الرسوميات المستخدمة لفتح SolidCraft. إذا تعذّر فتح النافذة عند التشغيل التالي، فستُستعاد السابقة.",
"prefs.graphics_api_override": "يتحكم إعداد عند التشغيل في واجهة الرسوميات. سيُطبّق الاختيار المحفوظ عند فتح SolidCraft بالطريقة العادية.",
"prefs.graphics_api_restart_note": "يُطبّق بعد إعادة التشغيل. تُعالَج التصميمات غير المحفوظة قبل الإغلاق.",
"prefs.graphics_api_restart_note": "يُطبّق بعد إعادة التشغيل. تُعالَج التصاميم غير المحفوظة قبل الإغلاق.",
"prefs.graphics_api_restart_unavailable": "أعد تشغيل SolidCraft لتطبيق واجهة الرسوميات المحفوظة.",
"prefs.graphics_api_running": "الحالية: {current}. المختارة للتشغيل التالي: {selected}.",
"prefs.graphics_api_save_failed": "تعذّر حفظ اختيار واجهة الرسوميات: {error}",
@@ -29,7 +29,7 @@
"prefs.graphics_api_start_reverted": "لم يفتح التشغيل السابق باستخدام {requested} النافذة. استعاد SolidCraft الاختيار {previous}.",
"prefs.graphics_api_unavailable": "اختيار واجهة الرسوميات متاح في تطبيق سطح المكتب.",
"render.graphics_api_restart_vulkan": "إعادة التشغيل باستخدام Vulkan",
"render.graphics_api_restart_warning": "يؤدي تغيير واجهة الرسوميات إلى إعادة تشغيل SolidCraft ويطلب منك حفظ التصميمات غير المحفوظة. إذا تعذّر فتح النافذة بالواجهة الجديدة، فسيُستعاد الاختيار السابق عند التشغيل التالي.",
"render.graphics_api_restart_warning": "يؤدي تغيير واجهة الرسوميات إلى إعادة تشغيل SolidCraft ويطلب منك حفظ التصاميم غير المحفوظة. إذا تعذّر فتح النافذة بالواجهة الجديدة، فسيُستعاد الاختيار السابق عند التشغيل التالي.",
"render.graphics_api_settings": "إعدادات الرسوميات",
"render.graphics_api_vulkan_required": "يتطلب تتبّع المسارات بمعالج الرسوميات Vulkan في هذه الجلسة. أعد التشغيل باستخدام Vulkan، أو واصل استخدام طريقة صور بمعالج CPU."
},
@@ -0,0 +1,34 @@
{
"en": {
"nav.maya_horizontal_zoom.help": "Alt+right drag: right zooms in, left zooms out. Turn off to restore vertical drag zoom.",
"nav.maya_horizontal_zoom.label": "Maya: horizontal drag zoom"
},
"ar": {
"nav.maya_horizontal_zoom.help": "اسحب مع Alt وزر الفأرة الأيمن: إلى اليمين للتكبير وإلى اليسار للتصغير. عطّل هذا الخيار لاستعادة التكبير بالسحب العمودي.",
"nav.maya_horizontal_zoom.label": "Maya: التكبير بالسحب الأفقي"
},
"he": {
"nav.maya_horizontal_zoom.help": "Alt וגרירה עם לחצן העכבר הימני: ימינה לקירוב, שמאלה להרחקה. כבו כדי לחזור לזום בגרירה אנכית.",
"nav.maya_horizontal_zoom.label": "Maya: זום בגרירה אופקית"
},
"ja": {
"nav.maya_horizontal_zoom.help": "Alt+右ドラッグで右に動かすと拡大、左に動かすと縮小します。オフにすると従来の垂直ドラッグに戻ります。",
"nav.maya_horizontal_zoom.label": "Maya: 水平方向のドラッグでズーム"
},
"vi": {
"nav.maya_horizontal_zoom.help": "Alt+kéo chuột phải: kéo sang phải để phóng to, sang trái để thu nhỏ. Tắt để khôi phục thu phóng bằng cách kéo dọc.",
"nav.maya_horizontal_zoom.label": "Maya: thu phóng bằng cách kéo ngang"
},
"zh": {
"nav.maya_horizontal_zoom.help": "Alt+右键拖动:向右放大,向左缩小。关闭此选项可恢复垂直拖动缩放。",
"nav.maya_horizontal_zoom.label": "Maya:水平拖动缩放"
},
"de": {
"nav.maya_horizontal_zoom.help": "Alt+Rechtsziehen: nach rechts vergrößern, nach links verkleinern. Ausschalten, um wieder vertikal zu zoomen.",
"nav.maya_horizontal_zoom.label": "Maya: horizontal ziehen zum Zoomen"
},
"ko": {
"nav.maya_horizontal_zoom.help": "Alt+오른쪽 버튼 드래그: 오른쪽으로 움직이면 확대하고 왼쪽으로 움직이면 축소합니다. 끄면 세로 드래그 확대/축소로 돌아갑니다.",
"nav.maya_horizontal_zoom.label": "Maya: 가로 드래그로 확대/축소"
}
}
@@ -0,0 +1,74 @@
{
"en": {
"ai.prefs.control_port": "Port",
"ai.prefs.control_port_note": "The port AI agents connect to on this computer (127.0.0.1). 27878 is the default: when it is in use, the next free port is taken. Any other port is used exactly. Point your MCP client at the same port, for example solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "Listening on 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "Not listening",
"ai.prefs.control_port_taken": "Port {port} is in use by another program (or another SolidCraft window). Choose another port.",
"ai.prefs.control_failed": "The control channel could not start: {error}",
"ai.prefs.control_from_launch": "Started with --control on port {port} for this run. Changes here take effect the next time SolidCraft starts without --control."
},
"de": {
"ai.prefs.control_port": "Port",
"ai.prefs.control_port_note": "Der Port, über den sich KI-Agenten auf diesem Computer verbinden (127.0.0.1). Standard ist 27878: Ist er belegt, wird der nächste freie Port genommen. Jeder andere Port wird genau so verwendet. Richte deinen MCP-Client auf denselben Port, zum Beispiel solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "Wartet auf 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "Nicht aktiv",
"ai.prefs.control_port_taken": "Port {port} wird von einem anderen Programm (oder einem anderen SolidCraft-Fenster) verwendet. Wähle einen anderen Port.",
"ai.prefs.control_failed": "Der Steuerkanal konnte nicht starten: {error}",
"ai.prefs.control_from_launch": "Für diese Sitzung mit --control auf Port {port} gestartet. Änderungen hier gelten beim nächsten Start von SolidCraft ohne --control."
},
"ja": {
"ai.prefs.control_port": "ポート",
"ai.prefs.control_port_note": "このコンピューター上で AI エージェントが接続するポート (127.0.0.1)。既定は 27878 で、使用中の場合は次の空いているポートを使います。それ以外のポートはそのまま使います。MCP クライアントも同じポートに向けてください (例: solidcraft-cli mcp --connect 127.0.0.1:7878)。",
"ai.prefs.control_listening": "127.0.0.1:{port} で待機中",
"ai.prefs.control_not_listening": "待機していません",
"ai.prefs.control_port_taken": "ポート {port} は別のプログラム (または別の SolidCraft ウィンドウ) が使用しています。別のポートを選んでください。",
"ai.prefs.control_failed": "コントロールチャネルを開始できませんでした: {error}",
"ai.prefs.control_from_launch": "この実行は --control でポート {port} を使って開始されました。ここでの変更は、次に --control なしで SolidCraft を起動したときに反映されます。"
},
"zh": {
"ai.prefs.control_port": "端口",
"ai.prefs.control_port_note": "AI 代理在本机上连接的端口 (127.0.0.1)。默认为 27878:若被占用,则使用下一个空闲端口。其他端口按原样使用。请让 MCP 客户端指向同一端口,例如 solidcraft-cli mcp --connect 127.0.0.1:7878。",
"ai.prefs.control_listening": "正在监听 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "未在监听",
"ai.prefs.control_port_taken": "端口 {port} 已被其他程序(或另一个 SolidCraft 窗口)占用。请选择其他端口。",
"ai.prefs.control_failed": "控制通道无法启动:{error}",
"ai.prefs.control_from_launch": "本次运行通过 --control 在端口 {port} 上启动。此处的更改将在下次不带 --control 启动 SolidCraft 时生效。"
},
"ko": {
"ai.prefs.control_port": "포트",
"ai.prefs.control_port_note": "이 컴퓨터에서 AI 에이전트가 연결하는 포트(127.0.0.1)입니다. 기본값은 27878이며, 사용 중이면 다음 빈 포트를 사용합니다. 다른 포트는 그대로 사용합니다. MCP 클라이언트도 같은 포트를 가리키게 하세요(예: solidcraft-cli mcp --connect 127.0.0.1:7878).",
"ai.prefs.control_listening": "127.0.0.1:{port}에서 대기 중",
"ai.prefs.control_not_listening": "대기하지 않음",
"ai.prefs.control_port_taken": "포트 {port}을(를) 다른 프로그램(또는 다른 SolidCraft 창)이 사용 중입니다. 다른 포트를 선택하세요.",
"ai.prefs.control_failed": "제어 채널을 시작할 수 없습니다: {error}",
"ai.prefs.control_from_launch": "이번 실행은 --control로 포트 {port}에서 시작되었습니다. 여기서 바꾼 내용은 다음에 --control 없이 SolidCraft를 시작할 때 적용됩니다."
},
"vi": {
"ai.prefs.control_port": "Cổng",
"ai.prefs.control_port_note": "Cổng mà các tác tử AI kết nối trên máy tính này (127.0.0.1). Mặc định là 27878: nếu cổng đang được dùng, cổng trống tiếp theo sẽ được dùng. Mọi cổng khác được dùng đúng như vậy. Hãy trỏ ứng dụng MCP của bạn đến cùng cổng, ví dụ solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "Đang lắng nghe trên 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "Không lắng nghe",
"ai.prefs.control_port_taken": "Cổng {port} đang được một chương trình khác (hoặc một cửa sổ SolidCraft khác) sử dụng. Hãy chọn cổng khác.",
"ai.prefs.control_failed": "Không thể khởi động kênh điều khiển: {error}",
"ai.prefs.control_from_launch": "Lần chạy này được khởi động với --control trên cổng {port}. Các thay đổi ở đây có hiệu lực vào lần tới SolidCraft khởi động mà không có --control."
},
"ar": {
"ai.prefs.control_port": "المنفذ",
"ai.prefs.control_port_note": "المنفذ الذي يتصل عبره وكلاء الذكاء الاصطناعي على هذا الحاسوب (127.0.0.1). الافتراضي هو 27878: إذا كان مستخدمًا يُستخدم المنفذ الحر التالي. أي منفذ آخر يُستخدم كما هو. وجّه عميل MCP إلى المنفذ نفسه، مثل solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "يستمع على 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "لا يستمع",
"ai.prefs.control_port_taken": "المنفذ {port} يستخدمه برنامج آخر (أو نافذة SolidCraft أخرى). اختر منفذًا آخر.",
"ai.prefs.control_failed": "تعذّر بدء قناة التحكم: {error}",
"ai.prefs.control_from_launch": "بدأ هذا التشغيل باستخدام --control على المنفذ {port}. تسري التغييرات هنا عند تشغيل SolidCraft في المرة القادمة دون --control."
},
"he": {
"ai.prefs.control_port": "פורט",
"ai.prefs.control_port_note": "הפורט שאליו סוכני AI מתחברים במחשב זה (127.0.0.1). ברירת המחדל היא 27878: אם הוא תפוס, נלקח הפורט הפנוי הבא. כל פורט אחר משמש בדיוק כפי שהוא. כוונו את לקוח ה-MCP לאותו פורט, למשל solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "מאזין ב-127.0.0.1:{port}",
"ai.prefs.control_not_listening": "לא מאזין",
"ai.prefs.control_port_taken": "פורט {port} בשימוש של תוכנה אחרת (או חלון SolidCraft אחר). בחרו פורט אחר.",
"ai.prefs.control_failed": "לא ניתן היה להפעיל את ערוץ הבקרה: {error}",
"ai.prefs.control_from_launch": "הפעלה זו התחילה עם --control בפורט {port}. שינויים כאן ייכנסו לתוקף בפעם הבאה ש-SolidCraft יופעל ללא --control."
}
}
@@ -120,9 +120,9 @@
"ui.right_click_confirms_tip": "對話方塊開啟時,在視圖中按右鍵會轉到下一個空白輸入,在最後一個輸入上執行 OK"
},
"ar": {
"dialog.right_click_pick": "اختر {input} أولاً",
"ui.right_click_confirms": "النقر بزر الماوس الأيمن يؤكد مدخلات مربع الحوار",
"ui.right_click_confirms_tip": "عندما يكون مربع حوار مفتوحاً، ينتقل النقر بزر الماوس الأيمن في العرض إلى المدخل الفارغ التالي، وعند المدخل الأخير ينفّذ موافق"
"dialog.right_click_pick": "حدد {input} أولًا",
"ui.right_click_confirms": "النقر بزر الفأرة الأيمن يؤكد مدخلات مربع الحوار",
"ui.right_click_confirms_tip": "عندما يكون مربع حوار مفتوحًا، ينتقل النقر بزر الفأرة الأيمن في العرض إلى المدخل الفارغ التالي، وعند المدخل الأخير ينفّذ «موافق»"
},
"he": {
"dialog.right_click_pick": "יש לבחור תחילה {input}",
+1 -1
View File
@@ -7,7 +7,7 @@
"document.save_row": "حفظ",
"document.save_selected": "حفظ المحدد",
"prefs.save_prompt_one_at_a_time": "السؤال عن كل تصميم غير محفوظ على حدة عند إغلاق عدة تصاميم",
"prefs.save_prompt_one_at_a_time_help": "عرض نافذة حفظ لكل تصميم غير محفوظ بدلاً من قائمة مجمّعة."
"prefs.save_prompt_one_at_a_time_help": "عرض نافذة حفظ لكل تصميم غير محفوظ بدلًا من قائمة مجمّعة."
},
"de": {
"document.discard_row": "Verwerfen",
@@ -0,0 +1,8 @@
{
"en": { "cmd.selection_get": "Get Selection" },
"ar": { "cmd.selection_get": "الحصول على التحديد" },
"he": { "cmd.selection_get": "קבלת הבחירה" },
"ja": { "cmd.selection_get": "選択を取得" },
"vi": { "cmd.selection_get": "Lấy vùng chọn" },
"zh": { "cmd.selection_get": "获取选择" }
}
@@ -5,7 +5,7 @@
},
"ar": {
"prefs.sketch.cursor_coordinates": "إظهار إحداثيات المؤشر في الرسم",
"prefs.sketch.cursor_coordinates_note": "بجانب المؤشر، قيمتا X وY من أصل الرسم، حتى دون تشغيل أي أداة. إيقاف: فقط أثناء وضع أداة لنقطة."
"prefs.sketch.cursor_coordinates_note": "بجانب المؤشر، قيمتا X وY من أصل الرسم، حتى دون تشغيل أي أداة. عند الإيقاف: فقط عندما تضع أداةٌ نقطة."
},
"de": {
"prefs.sketch.cursor_coordinates": "Cursorkoordinaten in der Skizze anzeigen",
@@ -5,8 +5,8 @@
"prefs.sketch.max_points_unit": "points"
},
"ar": {
"prefs.sketch.max_points": "أكبر رسم تخطيطي",
"prefs.sketch.max_points_note": "أكبر عدد من النقاط أو المنحنيات في رسم تخطيطي واحد: 20000 افتراضيًا، و200000 كحد أقصى. يرفض إدراج SVG وإدراج DXF رسمًا أكبر. الرسم التخطيطي الكبير يُحل ويُعرض بشكل أبطأ.",
"prefs.sketch.max_points": "أكبر حجم للرسم",
"prefs.sketch.max_points_note": "أكبر عدد من النقاط أو المنحنيات في رسم واحد: 20000 افتراضيًا، و200000 كحد أقصى. يرفض «إدراج SVG» و«إدراج DXF» رسمًا أكبر. الرسم الكبير يُحل ويُعرض بشكل أبطأ.",
"prefs.sketch.max_points_unit": "نقطة"
},
"de": {
+1214 -212
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
| assets/locales/pending/agent-chat-select-all.json | SolidCraft contributors | Original assistant chat select-all strings and translations | MIT OR Apache-2.0 |
@@ -0,0 +1 @@
| assets/locales/pending/constraint-glyph-size.json | SolidCraft contributors | Original constraint glyph size preference label and help text | MIT OR Apache-2.0 |
+118
View File
@@ -0,0 +1,118 @@
//! Where a control request is, so the connection that sent it can cancel it (`control.cancel`,
//! #1989): shared between the window, which moves it along, and the connection. Whoever changes
//! it first wins.
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Mutex, PoisonError};
/// A request's place in the app, and how to stop the work computing it.
#[derive(Default)]
pub struct Ticket {
state: AtomicU8,
/// It is an export computing on a copy, whose worker writes its file.
writes_file: AtomicBool,
/// Tells its worker to stop: set by the window when it hands the request to one.
stopper: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
}
impl std::fmt::Debug for Ticket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ticket").field("state", &self.state).field("writes_file", &self.writes_file).finish_non_exhaustive()
}
}
/// Waiting its turn (sent, or held behind another request).
const WAITING: u8 = 0;
/// Running on the window thread (or handed to something that can't stop it, such as the file
/// loader).
const RUNNING: u8 = 1;
/// Computing off the window thread.
const COMPUTING: u8 = 2;
const CANCELLED: u8 = 3;
/// What [`Ticket::cancel`] did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cancel {
/// It was waiting its turn: it will not run.
Removed,
/// It was computing off the window thread: it is answered as stopped and nothing of it is
/// applied. An export's worker may still write its file.
Stopped { writes_file: bool },
/// It runs on the window thread: it will finish.
Running,
/// It was cancelled or stopped before.
Already,
}
impl Ticket {
/// Cancel the request, unless it already runs on the window thread.
pub fn cancel(&self) -> Cancel {
let mut state = self.state.load(Ordering::Acquire);
loop {
let done = match state {
WAITING => Cancel::Removed,
COMPUTING => Cancel::Stopped { writes_file: self.writes_file.load(Ordering::Acquire) },
CANCELLED => return Cancel::Already,
_ => return Cancel::Running,
};
match self.state.compare_exchange(state, CANCELLED, Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => {
self.stop_worker();
return done;
}
Err(now) => state = now,
}
}
}
pub fn is_cancelled(&self) -> bool {
self.state.load(Ordering::Acquire) == CANCELLED
}
/// The window takes the request to run it: false when it was cancelled.
pub fn take(&self) -> bool {
self.moves(WAITING, RUNNING)
}
/// The work computing it stops on `stop` (a worker's own stop flag). Cancelled already, it is
/// stopped at once.
pub fn on_stop(&self, stop: impl Fn() + Send + Sync + 'static) {
*self.stopper.lock().unwrap_or_else(PoisonError::into_inner) = Some(Box::new(stop));
if self.is_cancelled() {
self.stop_worker();
}
}
/// The window hands it to a worker (`writes_file`: an export on a copy).
pub fn computing(&self, writes_file: bool) {
self.writes_file.store(writes_file, Ordering::Release);
let _ = self.moves(RUNNING, COMPUTING) || self.moves(WAITING, COMPUTING);
}
/// Its worker is done and it runs on the window thread now (or it never left): false when it
/// was cancelled meanwhile. Only the window moves it on from running, so running stays running.
pub fn back(&self) -> bool {
self.moves(COMPUTING, RUNNING) || self.state.load(Ordering::Acquire) == RUNNING
}
/// Computed, but it waits its turn again (a file opening meanwhile): false when cancelled.
pub fn wait_again(&self) -> bool {
self.moves(COMPUTING, WAITING)
}
/// The person at the app stopped it (Stop): its worker stops too.
pub fn stop(&self) {
self.state.store(CANCELLED, Ordering::Release);
self.stop_worker();
}
fn stop_worker(&self) {
if let Some(stop) = self.stopper.lock().unwrap_or_else(PoisonError::into_inner).as_ref() {
stop();
}
}
fn moves(&self, from: u8, to: u8) -> bool {
self.state.compare_exchange(from, to, Ordering::AcqRel, Ordering::Acquire).is_ok()
}
}
+2
View File
@@ -8,10 +8,12 @@
#![forbid(unsafe_code)]
pub mod auth;
mod cancel;
mod effect;
mod request;
#[cfg(not(target_arch = "wasm32"))]
pub mod server;
pub use cancel::{Cancel, Ticket};
pub use effect::Effect;
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
+4 -1
View File
@@ -18,12 +18,15 @@ pub struct ControlRequest {
/// Chat requests already admitted by the gate may outlive a tab switch. This turn's
/// validity follows them through queues, computation and cursor animation.
pub chat_turn: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// Where the request is, shared with the connection that sent it so it can cancel it
/// (`control.cancel`, #1989).
pub ticket: std::sync::Arc<crate::Ticket>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None }, rx)
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None, ticket: Default::default() }, rx)
}
/// Answer a request whose agent chat turn has ended, and say so: such a request is not run.
+275 -10
View File
@@ -14,6 +14,7 @@ use serde_json::{Value, json};
use crate::Effect;
use crate::auth::tokens_match;
use crate::cancel::{Cancel, Ticket};
use crate::request::{ControlRequest, DOCUMENT_WATCH, DocumentWatch, Subscriber};
/// What the server needs to know of a request before it sends it to the window: how to name it
@@ -94,28 +95,101 @@ impl Hub {
/// on it). A listener that is not on a loopback address is refused: the channel never goes on
/// the network.
pub fn listen(self: &Arc<Self>, listener: TcpListener, token: String, requests: Sender<ControlRequest>, patient: bool) -> std::io::Result<()> {
self.listen_stoppable(listener, token, requests, patient).map(drop)
}
/// [`Hub::listen`], and a [`Stop`] that closes the listener and the connections it accepted
/// (a channel turned off in the app's settings). Dropping the [`Stop`] leaves it serving.
pub fn listen_stoppable(
self: &Arc<Self>,
listener: TcpListener,
token: String,
requests: Sender<ControlRequest>,
patient: bool,
) -> std::io::Result<Stop> {
let addr = listener.local_addr()?;
if !addr.ip().is_loopback() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("the control channel listens on loopback only, not {addr}")));
}
let hub = Arc::clone(self);
let token = Arc::new(token);
let shared = Arc::new(Accepted::default());
let (closed_tx, closed) = channel::<()>();
let stop = Stop { addr, shared: Arc::clone(&shared), closed };
std::thread::spawn(move || {
// Dropped when this thread ends, with the listener: `Stop::stop` waits for that.
let _closed = closed_tx;
for stream in listener.incoming().flatten() {
if shared.stopped.load(Ordering::SeqCst) {
break;
}
if hub.open.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
hub.open.fetch_sub(1, Ordering::Relaxed);
let mut refused = stream;
let _ = send(&mut refused, &json!({"ok": false, "error": "too many connections"}));
continue;
}
let (hub, tx, token) = (Arc::clone(&hub), requests.clone(), Arc::clone(&token));
let id = shared.next.fetch_add(1, Ordering::Relaxed);
if let Ok(copy) = stream.try_clone() {
shared.lock().push((id, copy));
}
// Stopped while this one was being accepted: it is closed with the rest.
if shared.stopped.load(Ordering::SeqCst) {
shared.close_all();
}
let (hub, tx, token, shared) = (Arc::clone(&hub), requests.clone(), Arc::clone(&token), Arc::clone(&shared));
std::thread::spawn(move || {
hub.serve(stream, &token, tx, patient);
hub.open.fetch_sub(1, Ordering::Relaxed);
shared.lock().retain(|(i, _)| *i != id);
});
}
});
Ok(())
Ok(stop)
}
}
/// The connections a stoppable listener accepted, so stopping it can close them too.
#[derive(Default)]
struct Accepted {
stopped: AtomicBool,
next: AtomicU64,
streams: Mutex<Vec<(u64, TcpStream)>>,
}
impl Accepted {
fn lock(&self) -> MutexGuard<'_, Vec<(u64, TcpStream)>> {
self.streams.lock().unwrap_or_else(PoisonError::into_inner)
}
fn close_all(&self) {
for (_, stream) in self.lock().drain(..) {
let _ = stream.shutdown(std::net::Shutdown::Both);
}
}
}
/// Stops a listener started with [`Hub::listen_stoppable`].
pub struct Stop {
addr: std::net::SocketAddr,
shared: Arc<Accepted>,
closed: Receiver<()>,
}
impl Stop {
/// The port the listener is on.
pub fn port(&self) -> u16 {
self.addr.port()
}
/// Stop accepting, close the port and end every connection it accepted. Returns once the
/// port is closed (at most a couple of seconds), so the same port can be bound again at once.
pub fn stop(self) {
self.shared.stopped.store(true, Ordering::SeqCst);
// The listener waits in `accept`: a connection of our own wakes it to see the flag.
let _ = TcpStream::connect_timeout(&self.addr, Duration::from_secs(1));
let _ = self.closed.recv_timeout(Duration::from_secs(2));
self.shared.close_all();
}
}
@@ -277,6 +351,62 @@ struct Pending {
/// The request in messages ("`engine.execute` (solid.fillet)").
what: String,
reply: Receiver<Value>,
/// Where it is in the app, to cancel it (`control.cancel`).
job: Arc<Ticket>,
}
/// `control.cancel {id?}`: cancel the request this connection still waits for (the one
/// `control.pending` reports; `id`, when given, must name it). A connection can only cancel its
/// own requests. Waiting its turn, it is removed; computing off the window thread, it is stopped
/// as the app's Stop stops it. Either way nothing of it is applied and the connection is free.
/// One that runs on the window thread can't be cancelled; one that finished is answered.
fn cancel(pending: &mut Option<Pending>, late: Option<Value>, id: Option<&Value>, document: &str) -> Value {
let id = id.filter(|v| !v.is_null());
let mine = pending.as_ref().filter(|p| id.is_none_or(|id| *id == p.id));
let Some(p) = mine else {
return match id {
None => json!({"ok": true, "result": {"cancelled": null, "note": "nothing to cancel: no request of this connection is running"}}),
Some(id) => json!({"ok": false, "error": format!(
"nothing to cancel: request {} is not running on this connection (a connection can only cancel its own requests)",
id.to_string().chars().take(64).collect::<String>()
)}),
};
};
let (what, id) = (format!("request {} ({})", p.id, p.what), p.id.clone());
let finished = |p: Pending, reply: Value| {
json!({"ok": true, "result": {"cancelled": null, "pending": p.id, "method": p.method, "done": true, "reply": reply,
"note": format!("nothing to cancel: {what} had already finished; its reply is here")}})
};
if let Some(reply) = late {
return pending.take().map_or(Value::Null, |p| finished(p, reply));
}
let outcome = p.job.cancel();
let note = match outcome {
Cancel::Removed => format!("cancelled: {what} was still waiting its turn and will not run; nothing was applied"),
Cancel::Stopped { writes_file: false } => format!("stopped: {what} was computing off the window thread; nothing was applied"),
Cancel::Stopped { writes_file: true } => {
format!("stopped: {what} was computing on a copy of the {document}, which is unchanged; its worker may still write the file")
}
Cancel::Already => format!("{what} was already cancelled or stopped; nothing of it was applied"),
Cancel::Running => {
// It may have just finished.
if let Some(reply) = pending.as_ref().and_then(|p| p.reply.try_recv().ok()) {
return pending.take().map_or(Value::Null, |p| finished(p, reply));
}
return json!({"ok": false, "cancellable": false, "error": format!(
"cannot be cancelled: {what} already runs in the app (on its window thread, or as a file it opens) and will \
still finish; ask `control.pending` for its result. Only a request still waiting its turn, or a change or \
export computing off the window thread, can be cancelled"
)});
}
};
let method = pending.take().map(|p| p.method);
let how = match outcome {
Cancel::Removed => "removed",
Cancel::Stopped { .. } => "stopped",
_ => "already",
};
json!({"ok": true, "result": {"cancelled": id, "method": method, "outcome": how, "note": note}})
}
/// Every request the app has been sent and not answered yet, from every connection, for
@@ -311,7 +441,7 @@ impl Running {
/// List `req`, sent on `connection`, until the returned ticket is dropped; `command` and
/// `steps` as the app's [`Protocol`] summarised it.
fn add(self: &Arc<Self>, connection: u64, id: &Value, req: &ControlRequest, command: Option<&'static str>, steps: Option<usize>) -> Ticket {
fn add(self: &Arc<Self>, connection: u64, id: &Value, req: &ControlRequest, command: Option<&'static str>, steps: Option<usize>) -> Listing {
let key = self.keys.fetch_add(1, Ordering::Relaxed);
// Ids and methods come from the client: only short ones are repeated to the others.
let id = match id {
@@ -322,7 +452,7 @@ impl Running {
let method = req.method.chars().take(64).collect();
let started = req.started.clone();
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
Ticket { running: Arc::clone(self), key }
Listing { running: Arc::clone(self), key }
}
/// The `control.running` result, as `asker`'s connection sees it.
@@ -355,12 +485,12 @@ impl Running {
}
/// A listed request; dropped when its reply arrives.
struct Ticket {
struct Listing {
running: Arc<Running>,
key: u64,
}
impl Drop for Ticket {
impl Drop for Listing {
fn drop(&mut self) {
let key = self.key;
self.running.lock().retain(|e| e.key != key);
@@ -369,7 +499,7 @@ impl Drop for Ticket {
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
/// until the app answers, also once its connection has closed (#3654).
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
fn watch(reply: Receiver<Value>, ticket: Listing) -> Receiver<Value> {
let (late_tx, late_rx) = channel();
// A small thread per timed-out request (each connection has at most one). If none can be
// made, the reply is lost and `control.pending` says the app ended the request without one.
@@ -395,11 +525,12 @@ pub fn timeout_message(what: &str, effect: Effect, document: &str) -> String {
}
Effect::WritesFile => format!(
"timeout: {what} is still running in the app and will still write the file when it finishes (it was not cancelled: \
don't send it again; the {document} does not change); ask `control.pending` for its result"
don't send it again; the {document} does not change); ask `control.pending` for its result, or `control.cancel` to \
cancel it"
),
Effect::Changes => format!(
"timeout: {what} is still running in the app and will still apply when it finishes (it was not cancelled: don't send it \
again); ask `control.pending` for its result"
again); ask `control.pending` for its result, or `control.cancel` to cancel it"
),
}
}
@@ -597,6 +728,10 @@ impl Hub {
json!({"ok": true, "result": running.result(connection)})
} else if method == "control.capabilities" {
json!({"ok": true, "result": self.protocol.capabilities()})
} else if method == "control.cancel" {
let r = cancel(&mut pending, late, params.get("id"), self.protocol.document());
(self.wake)();
r
} else if method == "control.pending" {
match (pending.take(), late) {
(None, _) => json!({"ok": true, "result": {"pending": null}}),
@@ -636,6 +771,7 @@ impl Hub {
// answer on.
joining = (method == "control.subscribe").then(|| documents.join());
let (req, rrx) = ControlRequest::new(method.clone(), params);
let job = Arc::clone(&req.ticket);
let ticket = running.add(connection, &id, &req, command, steps);
if tx.send(req).is_err() {
break;
@@ -645,7 +781,7 @@ impl Hub {
Ok(r) => r,
Err(_) => {
let r = json!({"ok": false, "running": true, "error": timeout_message(&what, effect, self.protocol.document())});
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket) });
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket), job });
r
}
}
@@ -807,6 +943,104 @@ mod tests {
assert!(l.contains("unauthorized"), "{l}");
}
/// #1989: `control.cancel` cancels the connection's own outstanding request. One still
/// waiting its turn is removed and never runs, and the connection is free at once; one that
/// already runs in the app can't be, and says so; another connection's request is refused;
/// with nothing outstanding there is nothing to cancel.
#[test]
fn control_cancel_cancels_only_this_connections_request() {
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let (gate_tx, gate_rx) = channel::<()>();
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let seen = ran.clone();
// A stand-in for the window thread: `queued` waits behind the others (never taken until
// `slow` is done), `slow` runs until the gate opens.
std::thread::spawn(move || {
let mut held = Vec::new();
for req in rx {
if req.method == "queued" {
held.push(req);
continue;
}
if !req.ticket.take() {
continue;
}
seen.lock().unwrap().push(req.method.clone());
if req.method == "slow" {
let _ = gate_rx.recv();
}
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
if req.method == "slow" {
for req in held.drain(..) {
if req.ticket.take() {
seen.lock().unwrap().push(req.method.clone());
}
}
}
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100), token: TOKEN_WAIT };
let hub = hub(waits, &DOCUMENT_WATCH);
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let (tx, hub) = (tx.clone(), Arc::clone(&hub));
std::thread::spawn(move || hub.serve(stream, token, tx, false));
}
});
let connect = || {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let out = s.try_clone().unwrap();
(out, BufReader::new(s))
};
let ask = |(out, lines): &mut (TcpStream, BufReader<TcpStream>), v: Value| -> Value {
writeln!(out, "{v}").unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
};
let mut a = connect();
let mut b = connect();
let r = ask(&mut b, json!({"id": 1, "method": "control.cancel", "token": token}));
assert!(r["ok"] == true && r["result"]["cancelled"].is_null(), "nothing to cancel: {r}");
// Waiting its turn: removed, never run, and the connection is free.
let r = ask(&mut a, json!({"id": 1, "method": "queued", "token": token}));
assert_eq!(r["running"], true, "{r}");
let r = ask(&mut a, json!({"id": 2, "method": "control.cancel", "params": {"id": 1}}));
assert_eq!((&r["ok"], &r["result"]["cancelled"], &r["result"]["outcome"]), (&json!(true), &json!(1), &json!("removed")), "{r}");
assert!(r["result"]["note"].as_str().is_some_and(|n| n.contains("will not run")), "{r}");
let r = ask(&mut a, json!({"id": 3, "method": "control.pending"}));
assert_eq!(r["result"]["pending"], Value::Null, "{r}");
// Already running in the app: it can't be, and it still answers.
let r = ask(&mut a, json!({"id": 4, "method": "slow"}));
assert_eq!(r["running"], true, "{r}");
let r = ask(&mut b, json!({"id": 2, "method": "control.cancel", "params": {"id": 4}}));
assert!(r["ok"] == false && r["error"].as_str().is_some_and(|e| e.contains("its own requests")), "another connection's: {r}");
let r = ask(&mut a, json!({"id": 5, "method": "control.cancel", "params": {"id": 99}}));
assert!(r["ok"] == false && r["error"].as_str().is_some_and(|e| e.starts_with("nothing to cancel")), "{r}");
let r = ask(&mut a, json!({"id": 6, "method": "control.cancel"}));
assert!(r["ok"] == false && r["cancellable"] == false, "{r}");
assert!(r["error"].as_str().is_some_and(|e| e.starts_with("cannot be cancelled") && e.contains("control.pending")), "{r}");
gate_tx.send(()).unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let r = loop {
let r = ask(&mut a, json!({"id": 7, "method": "control.pending"}));
if r["result"]["done"] == true || Instant::now() > deadline {
break r;
}
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(r["result"]["reply"]["result"], "slow", "{r}");
let r = ask(&mut a, json!({"id": 8, "method": "control.cancel"}));
assert!(r["ok"] == true && r["result"]["cancelled"].is_null(), "{r}");
assert_eq!(*ran.lock().unwrap(), ["slow"], "the cancelled request never ran");
}
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
/// is queued to run unseen behind it.
@@ -1154,4 +1388,35 @@ mod tests {
assert_eq!(out.bytes.last(), Some(&b'\n'));
assert_eq!(serde_json::from_slice::<Value>(&out.bytes).unwrap(), reply);
}
/// A stopped listener frees its port at once and ends the connections it had, so a channel
/// turned off in the settings leaves nothing listening and can be turned on again on the
/// same port.
#[test]
fn a_stopped_listener_closes_its_port_and_its_connections() {
use std::io::Read;
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, _rx) = channel();
let stop = hub(Waits::default(), &DOCUMENT_WATCH).listen_stoppable(listener, "0123456789abcdef0123456789abcdef".into(), tx, false).unwrap();
assert_eq!(stop.port(), port);
let mut client = TcpStream::connect(("127.0.0.1", port)).unwrap();
client.set_read_timeout(Some(Duration::from_secs(4))).unwrap();
// Wait until the server holds the connection, so stopping has one to close.
let held = Instant::now();
while stop.shared.lock().is_empty() && held.elapsed() < Duration::from_secs(4) {
std::thread::sleep(Duration::from_millis(5));
}
let started = Instant::now();
stop.stop();
let mut byte = [0u8; 1];
match client.read(&mut byte) {
Ok(n) => assert_eq!(n, 0, "the connection ends"),
Err(e) => {
assert!(!matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut), "the connection ends, not waits: {e}")
}
}
assert!(started.elapsed() < Duration::from_secs(3), "before the token wait runs out");
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok(), "the port is free again");
}
}
+4
View File
@@ -1033,6 +1033,10 @@ pub enum FeatureKind {
length: Option<Expr>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
/// Continuous flank faces for this feature. Missing in older designs: keep
/// their half-turn faces and groove-before-chamfer order for saved references.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
continuous_flanks: bool,
/// A point near the end of the face the thread starts at; `None`: the face's only free
/// end (no material past it), or the end the pick at `face` lies near.
#[serde(default, skip_serializing_if = "Option::is_none")]
+129 -1
View File
@@ -3717,6 +3717,70 @@ pub(crate) fn empty_intersection(bodies: &[&str]) -> DocError {
DocError::Invalid(format!("the intersection is empty: nothing of {body} would remain; check that the profile overlaps the body"))
}
/// The body a feature's target name `name` picks. Body names are unique, so a body of the
/// feature's component asked to be called a name another component's body already has (a
/// linked component's `AS5047P_PCB`) was named otherwise (`Body1`), and the name still picks the
/// other component's body. That is refused with both bodies listed, instead of running the
/// boolean on the other component's body and failing there with a kernel error (#12141).
pub(crate) fn named_target(doc: &Document, state: &ModelState, f: &Feature, name: &str) -> Result<usize> {
let i = state.bodies.iter().position(|b| b.name == name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?;
let Some(found) = state.bodies.get(i).filter(|b| b.component != f.component) else { return Ok(i) };
let asked: Vec<&str> =
asked_names(doc, state).filter(|(b, asked)| b.component == f.component && *asked == Some(name)).map(|(b, _)| b.name.as_str()).collect();
if asked.is_empty() {
return Ok(i);
}
let component = |id: u64| doc.components.iter().find(|c| c.id == id).map_or_else(|| "the root".to_string(), |c| format!("`{}`", c.name));
Err(DocError::Invalid(format!(
"the body name `{name}` is ambiguous: it names the body of component {}, while this feature's component {} has {} that was asked to be called `{name}` (the name was taken); target {} or rename the bodies so each name is used once",
component(found.component),
component(f.component),
asked.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", "),
asked.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(" or "),
)))
}
/// Each body with the name it was asked to be called: its slot of its feature's stored names.
/// An Import's skipped solids make no body, so its bodies take the slots left, in order; any
/// other feature's k-th body takes slot k. Siblings a later feature removed shift the count, so
/// a body then reads a later slot's name (only ever refusing an ambiguous target). One pass
/// over the bodies, each feature's slots listed once, so a large import costs linear time.
fn asked_names<'a, 's>(doc: &'a Document, state: &'s ModelState) -> impl Iterator<Item = (&'s ModelBody, Option<&'a str>)> {
let mut made: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
let mut slots: std::collections::HashMap<u64, Vec<usize>> = std::collections::HashMap::new();
state.bodies.iter().map(move |b| {
let j = made.entry(b.feature).or_insert(0);
let k = *j;
*j += 1;
let asked = doc.feature(b.feature).and_then(|m| {
let slot = match &m.kind {
FeatureKind::Import { skip, .. } => *slots
.entry(m.id)
.or_insert_with(|| {
let skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
(0..m.body_names.len()).filter(|s| !skip.contains(&(*s as u64))).collect()
})
.get(k)?,
_ => k,
};
m.body_names.get(slot).map(String::as_str).filter(|n| !n.is_empty())
});
(b, asked)
})
}
/// Refuse the target names among `names` that [`named_target`] finds ambiguous, before any
/// work that does not go through it (a sheet's cut-outs are machined before the boolean). Names
/// that pick no body are left to the feature, which may allow them.
fn refuse_ambiguous_targets(doc: &Document, state: &ModelState, f: &Feature, names: &[String]) -> Result<()> {
for name in names.iter().filter(|n| *n != "*") {
if let Err(e @ DocError::Invalid(_)) = named_target(doc, state, f, name) {
return Err(e);
}
}
Ok(())
}
/// The bodies a feature's boolean takes part in, and whether they were named rather than taken
/// by default (every body, or the feature's component).
pub(crate) fn boolean_scope(state: &ModelState, f: &Feature, targets: &[String]) -> (bool, Vec<String>) {
@@ -3812,7 +3876,7 @@ fn apply_traced_with(
} else {
let mut v = Vec::new();
for t in targets {
v.push(state.bodies.iter().position(|b| &b.name == t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?);
v.push(named_target(doc, state, f, t)?);
}
v
};
@@ -8242,6 +8306,7 @@ fn eval_feature(
) -> Result<()> {
// Its tools meet bodies in other placements where those are shown (#3383).
let _frames = ToolFrames::install(doc, f, st);
refuse_ambiguous_targets(doc, st, f, &f.participants)?;
match &f.kind {
FeatureKind::NewerFileFeature { raw } => Err(DocError::Invalid(raw.why())),
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
@@ -8290,6 +8355,7 @@ fn eval_feature(
| FeatureKind::Loft { operation, targets, .. }
| FeatureKind::Coil { operation, targets, .. }
| FeatureKind::Sweep { operation, targets, .. } => {
refuse_ambiguous_targets(doc, st, f, &crate::plain(targets))?;
let mut constructor = None;
let mut to_body = None;
let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor, &mut to_body, warning)?;
@@ -10146,6 +10212,68 @@ mod world_placement_cache_tests {
assert!(placed.positions.iter().all(|p| mapped.iter().any(|q| p.dist(*q) < 1e-8)));
}
/// A body asked to be called a name a linked component's body has is named otherwise; a
/// later feature of its component that targets the name is refused with both bodies listed,
/// not run on the other component's body (#12141).
#[test]
fn a_target_name_taken_by_another_component_is_refused_as_ambiguous() {
let mut doc = Document::new("names");
let linked = doc.add_component(Some("Encoder"), 0).unwrap();
let local = doc.add_component(Some("Assembly"), 0).unwrap();
let mut boxes = |comp: u64, operation: Operation, corner: Vec3| {
let id = doc
.add_feature(
FeatureKind::Box { corner: crate::point_expr(corner), length: "10".into(), width: "10".into(), height: "2".into(), operation },
None,
)
.unwrap();
let f = doc.feature_mut(id).unwrap();
f.component = comp;
id
};
let a = boxes(linked, Operation::NewBody, Vec3::new(100., 0., 0.));
let b = boxes(local, Operation::NewBody, Vec3::ZERO);
let cut = boxes(local, Operation::Cut, Vec3::new(2., 2., -1.));
doc.feature_mut(a).unwrap().body_names = vec!["AS5047P_PCB".into()];
doc.feature_mut(b).unwrap().body_names = vec!["AS5047P_PCB".into()];
if let FeatureKind::Box { length, width, .. } = &mut doc.feature_mut(cut).unwrap().kind {
*length = "2".into();
*width = "2".into();
}
doc.feature_mut(cut).unwrap().participants = vec!["AS5047P_PCB".into()];
let mut model = Model::new();
model.evaluate(&doc);
let st = model.state();
let own = st.bodies.iter().find(|x| x.component == local).unwrap().name.clone();
assert_ne!(own, "AS5047P_PCB", "names stay unique");
let err = model.result(cut).and_then(|r| r.error.clone()).expect("the cut is refused");
assert!(err.contains("ambiguous") && err.contains("`Encoder`") && err.contains(&format!("`{own}`")), "{err}");
// The linked body is left alone.
let linked_body = st.body("AS5047P_PCB").unwrap();
assert_eq!(linked_body.component, linked);
// Targeting the local body by the name it has works.
doc.feature_mut(cut).unwrap().participants = vec![own.clone()];
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.result(cut).and_then(|r| r.error.clone()).is_none());
}
/// An Import's skipped solids make no body: its bodies take the stored names left, so one
/// built from slot B was asked for B, not for the skipped slot's A (#12141).
#[test]
fn an_imports_body_is_asked_for_its_own_slots_name() {
let mut doc = Document::new("names");
let id =
doc.add_feature(FeatureKind::Import { file: "x.step".into(), step: "".into(), components: Vec::new(), skip: vec![0] }, None).unwrap();
doc.feature_mut(id).unwrap().body_names = vec!["A".into(), "B".into()];
let mut state = ModelState::default();
state.bodies.push(ModelBody::new("B (2)".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(1., 1., 1.)).unwrap(), id));
assert_eq!(asked_names(&doc, &state).next().unwrap().1, Some("B"));
doc.feature_mut(id).unwrap().kind = FeatureKind::Import { file: "x.step".into(), step: "".into(), components: Vec::new(), skip: Vec::new() };
assert_eq!(asked_names(&doc, &state).next().unwrap().1, Some("A"));
}
#[test]
fn identity_shares_mesh_but_rotated_instances_keep_distinct_physical_coordinates_and_source_names() {
let mut doc = Document::new("placements");
+52
View File
@@ -141,6 +141,7 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
angle: 0.0,
suppressed: false,
exprs: Default::default(),
captured: None,
});
// One body supplies both old nearest-face snaps and persistent named-face snaps.
// A scratch model shares its measurements, even though each sample changes the pose.
@@ -1412,6 +1413,7 @@ fn marking(p: Vec3) -> FeatureKind {
chamfer_angle: None,
chamfer_depth: None,
steep_flank: Default::default(),
continuous_flanks: false,
old_auto_size: false,
}
}
@@ -2000,6 +2002,56 @@ fn a_hole_classifies_the_faces_it_reads_not_every_face_of_the_design() {
assert!((side - Vec3::new(-1.0, 0.0, 0.0)).len() < 1e-6, "into the side wall: {side:?}");
}
/// A cut whose target name is taken by another component's sheet, while a body of the cut's own
/// component was asked for that name, is refused before the sheet is machined (#12141).
#[test]
fn an_ambiguous_target_is_refused_before_a_sheet_is_machined() {
let mut doc = Document::new("names");
let linked = doc.add_component(Some("Encoder"), 0).unwrap();
let local = doc.add_component(Some("Assembly"), 0).unwrap();
flat_sheet(&mut doc, Vec2::new(0.0, 0.0), 40.0, 40.0);
for f in doc.features.iter_mut() {
f.component = linked;
}
let base = doc.features.last().unwrap().id;
doc.feature_mut(base).unwrap().body_names = vec!["Plate".into()];
let own = doc
.add_feature(
FeatureKind::Box {
corner: point_expr(Vec3::new(100.0, 0.0, 0.0)),
length: "10".into(),
width: "10".into(),
height: "2".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
doc.feature_mut(own).unwrap().component = local;
doc.feature_mut(own).unwrap().body_names = vec!["Plate".into()];
let cut = doc
.add_feature(
FeatureKind::Box {
corner: point_expr(Vec3::new(10.0, 10.0, -5.0)),
length: "5".into(),
width: "5".into(),
height: "20".into(),
operation: Operation::Cut,
},
None,
)
.unwrap();
doc.feature_mut(cut).unwrap().component = local;
doc.feature_mut(cut).unwrap().participants = vec!["Plate".into()];
let mut model = Model::new();
model.evaluate(&doc);
let err = model.result(cut).and_then(|r| r.error.clone()).expect("the cut is refused");
assert!(err.contains("ambiguous") && err.contains("`Encoder`"), "{err}");
let st = model.state();
let sheet = st.sheets.iter().find(|s| s.body == "Plate").expect("the linked sheet");
assert!(sheet.holes.is_empty(), "the linked sheet is not machined");
}
/// A hole's look at the material over its entry face (#10695) is best effort: when the kernel
/// cannot build the run-on's column (here a column of no width), the hole keeps its full run-on
/// and cuts nothing out of its tools, as before the check, instead of failing.
+34 -17
View File
@@ -765,6 +765,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
designation,
length,
modeled,
continuous_flanks,
from,
up_to,
form,
@@ -827,7 +828,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
None => note,
});
}
if let Some(note) = cut(st, on, (*tip, angle, depth))? {
if let Some(note) = cut_with_flanks(st, on, (*tip, angle, depth), *continuous_flanks)? {
*warning = Some(match warning.take() {
Some(w) => format!("{w}; {note}"),
None => note,
@@ -846,11 +847,18 @@ pub(super) fn runout(vals: &BTreeMap<String, Value>, e: &Option<crate::ThreadRun
})
}
/// Cut a modelled thread resolved on its face into its body: the grooves (SMLib's exact screw
/// sweeps, a taper's crests by its envelope) by one B-rep boolean, then the chamfered tip
/// (`tip`, with its angle and depth when given). What the user should be told about the result, if
/// anything, is returned.
/// Cut a modelled thread with its historical half-turn faces and groove-before-chamfer
/// order. Existing hole features use this layout; Thread features keep their saved choice.
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<Option<String>> {
cut_with_flanks(st, on, (tip, angle, depth), false)
}
fn cut_with_flanks(
st: &mut ModelState,
on: ThreadOn,
(tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>),
continuous_flanks: bool,
) -> Result<Option<String>> {
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side, narrow, shoulder_unchecked } = on;
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
@@ -888,11 +896,28 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
}
false => None,
};
// Preserve historical construction order when rebuilding an older feature.
let chamfer = |mut input: Traced| -> Result<Traced> {
if tip != ThreadTip::None {
for cone in tip_cones(&t, radius, ends, (tip, from_side), angle, depth, taper)? {
let faces = cone.face_count();
let cut =
kernel::boolean(&input.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
input = Traced { body: cut, lineage };
}
}
Ok(input)
};
if continuous_flanks {
input = chamfer(input)?;
}
let cut_grooves = |ends: [End; 2]| -> Result<(Option<Body>, usize)> {
let mut grooves = Vec::new();
for g in thread_grooves(&t, groove_radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
let section = Region2 { outer: solidcore_record::Loop2::polygon(&g.section), holes: Vec::new() };
grooves.push(kernel::sweep_helix_radial(&g.plane, &section, g.base, g.axis, g.pitch, g.turns, &g.law)?);
grooves.push(kernel::sweep_helix_radial(&g.plane, &section, g.base, g.axis, g.pitch, g.turns, &g.law, continuous_flanks)?);
}
if let Some(taper) = taper
&& let Some(envelope) = taper_envelope(&t, radius, ends, taper)?
@@ -932,16 +957,8 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
// The groove's faces have no role to name them by.
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
let mut nb = Traced { body: nb, lineage };
if tip != ThreadTip::None {
for cone in tip_cones(&t, radius, ends, (tip, from_side), angle, depth, taper)? {
let faces = cone.face_count();
let cut = kernel::boolean(&nb.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
let lineage = nb.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
nb = Traced { body: cut, lineage };
}
}
let nb = Traced { body: nb, lineage };
let nb = if continuous_flanks { nb } else { chamfer(nb)? };
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
@@ -951,7 +968,7 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
/// The chamfered tip of an external thread (ISO 4753, chamfered end CH) at the free ends `tip`
/// names (`Start`: the end of the axis `from`, which must be free; `Both`: each free end):
/// the material outside a cone `angle` to the axis (default 45°) that reaches the diameter
/// d_f = d - 2 `depth` at the face's end, as a ring to cut after the groove, so the last teeth
/// d_f = d - 2 `depth` at the face's end, as a ring to cut from the stock, so the last teeth
/// taper into it. ISO 4753 bounds d_f by the external thread's minor diameter; the default is
/// that bound, d3 = d - 1.226869 P (ISO 724, the rounded root of the design profile). It lies
/// below the modelled basic root d1, so the cone crosses the root cylinder and leaves a whole
@@ -0,0 +1,5 @@
//! Joints save their captured values: `captured`, the values Capture Position
//! (`component.capture_position`) saved and Revert Position (`component.revert_position`) puts
//! the joint back to. An older design has none, so its joints load with their values as before
//! and Revert Position leaves them where they are. The last release warns about the newer format
//! number, opens the design and keeps the `captured` values it can't read.
@@ -0,0 +1,3 @@
//! Saves each thread feature's continuous-flank layout choice. Older threads omit
//! the field and keep their historical faces and chamfer order, preserving references.
//! The previous release refuses designs with this newer format rather than losing it.
+5
View File
@@ -458,6 +458,10 @@ pub struct Joint {
/// again whenever the design changes, so a joint follows the parameters it is defined by.
#[serde(default, skip_serializing_if = "JointExprs::is_empty")]
pub exprs: JointExprs,
/// The values Capture Position saved, which Revert Position puts the joint back to. None in
/// files from before captured joint values: such a joint keeps its values on Revert Position.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub captured: Option<Vec<f64>>,
}
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
@@ -2751,6 +2755,7 @@ mod tests {
angle: 0.0,
suppressed: false,
exprs: Default::default(),
captured: None,
})
.collect();
let links =
+1
View File
@@ -944,6 +944,7 @@ mod tests {
angle: 0.0,
suppressed: false,
exprs: Default::default(),
captured: None,
};
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
+1
View File
@@ -93,6 +93,7 @@ mod tests {
angle: 0.6,
suppressed: false,
exprs: Default::default(),
captured: None,
}
}
+6
View File
@@ -160,6 +160,12 @@ pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
stop.within(|| solidcraft_kernel::watch::watching(kernel, f))
}
/// Whether the watch over this thread's evaluations asked them to stop. Work done after that may
/// have been cut short: it is not kept.
pub fn cancelled() -> bool {
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|cur| cur.progress.is_cancelled()))
}
/// Entered at the start of an evaluation; the guard leaves it.
pub(crate) struct Evaluation {
outer: bool,
+106
View File
@@ -114,6 +114,112 @@ fn parametric_plate_reevaluates() {
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
}
/// #14256: an end plate whose 25 mm arc meets two lines tangentially, each joint held by two
/// "point on curve" constraints instead of a shared point. The solver leaves the arc's ends on
/// the lines only to its 1e-9 mm residual, so along the tangent they lie up to 2.25e-4 mm from
/// the lines' ends. The outline is still one closed profile, and extruding it gives the plate.
#[test]
fn an_outline_closed_by_point_on_curve_tangent_joints_extrudes() {
let mut doc = Document::new("end plate");
let mut sk = Sketch::new();
let knee = (50.0_f64.powi(2) - 11.125_f64.powi(2)).sqrt();
let mut point = |x: f64, y: f64| sk.add_point(Vec2::new(x, y), None).unwrap();
let corners = [(-14.0, 90.0), (14.0, 90.0), (14.0, 48.0), (knee, 11.125), (90.0, 11.125), (90.0, -11.125), (25.0, -11.125), (25.0, 0.0)];
let [top_left, top_right, shoulder, knee_point, right_top, right_bottom, step_bottom, step_top] = corners.map(|(x, y)| point(x, y));
// Where the solver left the 25 mm arc's ends, and the end of the line along its top.
let arc_start = point(24.999999999949498, 5.025035674136865e-5);
let arc_end = point(0.0002252128472149822, 24.999999999324313);
let ledge_right = point(0.0, 25.0);
let ledge_left = point(-14.0, 25.0);
let centre = point(0.0, 0.0);
let small_centre = point(0.0, 0.0);
let pos = |sk: &Sketch, i: usize| sk.points[i].pos;
let line = |sk: &mut Sketch, a: usize, b: usize| sk.add_line_pts(a, b, None).unwrap();
line(&mut sk, top_left, top_right);
line(&mut sk, top_right, shoulder);
sk.add_arc(Vec2::ZERO, pos(&sk, knee_point), pos(&sk, shoulder), [Some(centre), Some(knee_point), Some(shoulder)], None).unwrap();
line(&mut sk, right_top, knee_point);
line(&mut sk, right_bottom, right_top);
line(&mut sk, step_bottom, right_bottom);
let side = line(&mut sk, step_top, step_bottom);
let arc = sk.add_arc(Vec2::ZERO, pos(&sk, arc_start), pos(&sk, arc_end), [Some(small_centre), Some(arc_start), Some(arc_end)], None).unwrap();
let ledge = line(&mut sk, ledge_left, ledge_right);
line(&mut sk, top_left, ledge_left);
use ConstraintKind::*;
for kind in [
Coincident { p: centre, q: 0 },
Coincident { p: small_centre, q: 0 },
Horizontal { l: ledge },
Vertical { l: side },
Tangent { a: ledge, b: arc },
Tangent { a: side, b: arc },
PointOnCurve { p: ledge_right, c: arc },
PointOnCurve { p: step_top, c: arc },
PointOnCurve { p: arc_start, c: side },
PointOnCurve { p: arc_end, c: ledge },
] {
sk.add_constraint(kind, None).unwrap();
}
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
doc.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: crate::SketchId(s),
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "10".into(),
direction: Direction::Positive,
distance2: None,
start_offset: None,
through_all: false,
second_follows_first: false,
taper: None,
corner: Default::default(),
to: None,
to_offset: None,
from: None,
to_body_apart: false,
},
operation: Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
let st = m.state();
assert_eq!(st.sketches[0].profiles.len(), 1);
assert_eq!(st.bodies.len(), 1);
// The polygon through the corners, plus the segment the 50 mm arc adds, less the segment the
// quarter of the 25 mm circle takes away, 10 mm deep.
let outline = [
(25.0, -11.125),
(90.0, -11.125),
(90.0, 11.125),
(knee, 11.125),
(14.0, 48.0),
(14.0, 90.0),
(-14.0, 90.0),
(-14.0, 25.0),
(0.0, 25.0),
(25.0, 0.0),
];
let polygon: f64 = (0..outline.len())
.map(|i| {
let ((x0, y0), (x1, y1)) = (outline[i], outline[(i + 1) % outline.len()]);
x0 * y1 - x1 * y0
})
.sum::<f64>()
/ 2.0;
let segment = |r: f64, sweep: f64| r * r / 2.0 * (sweep - sweep.sin());
let area = polygon + segment(50.0, 48.0_f64.atan2(14.0) - 11.125_f64.atan2(knee)) - segment(25.0, std::f64::consts::FRAC_PI_2);
let volume = measure(&st.bodies[0].body).unwrap().volume;
assert!(rel(volume, area * 10.0) < 1e-6, "{volume} vs {}", area * 10.0);
}
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
/// the evaluation reports the features it restored (their geometry did change back).
#[test]
+11 -5
View File
@@ -279,14 +279,16 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("capture")
.contract(&CAPTURE_POSITION_CONTRACT)
.output(output_schema_for::<CapturePositionResult>)
.params("keeps pending occurrence moves"),
.params("keeps pending occurrence moves and saves every moving joint's values → captured, joints"),
CommandSpec::new("component.revert_position", "Revert Position", crate::checked!("component.revert_position", revert))
.at("SOLID", "POSITION")
.icon("revert")
.noundo()
.contract(&REVERT_POSITION_CONTRACT)
.output(output_schema_for::<RevertPositionResult>)
.params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
.params(
"occurrences?: [ids] (default all); drops selected pending occurrence moves; with no occurrences the joints also go back to \
the values Capture Position saved (clamped to limits) → reverted, joints",
),
];
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
@@ -1005,8 +1007,9 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
o.transform = m;
}
}
let joints = super::joints::capture_values(s);
s.revision += 1;
reply(CapturePositionResult { captured: n, param_warnings: Vec::new() })
reply(CapturePositionResult { captured: n, joints, param_warnings: Vec::new() })
}
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1027,8 +1030,11 @@ fn revert(s: &mut Session, p: &Value) -> Result<Value> {
s.pending_moves.clear();
}
let n = before - s.pending_moves.len();
// Every occurrence going back takes the joints back too; a revert of some occurrences
// leaves the joints alone.
let joints = if p.get("occurrences").is_none() { super::joints::revert_values(s) } else { 0 };
s.revision += 1;
reply(RevertPositionResult { reverted: n, param_warnings: Vec::new() })
reply(RevertPositionResult { reverted: n, joints, param_warnings: Vec::new() })
}
/// The active component's frame from world coordinates (for new features' geometry).
@@ -23,6 +23,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
angle: 0.1,
suppressed: false,
exprs: Default::default(),
captured: None,
}
}
+6
View File
@@ -2109,6 +2109,12 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
{
parameters.entry("convention").or_insert_with(|| json!(convention));
}
if let FeatureKind::Thread { continuous_flanks, .. } = &old.kind
&& cid == "solid.thread"
&& let Some(parameters) = params.as_object_mut()
{
parameters.entry("continuous_flanks").or_insert_with(|| json!(continuous_flanks));
}
// A thread saved before holes were sized by their nominal diameter keeps the size it had
// when it is edited without naming one.
if let FeatureKind::Thread { old_auto_size: true, .. } = &old.kind
+230 -2
View File
@@ -1,9 +1,11 @@
//! Edit Feature must replace the selections as well as the numerical definition.
//! Edit Feature must replace the selections as well as the numerical definition; the selection
//! commands (`select.set`, `selection.get`).
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_geom::MeshGeom;
use crate::{Sel, Session};
use crate::{EngineError, Sel, Session};
fn run(s: &mut Session, id: &str, params: Value) -> Value {
s.execute(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
@@ -2154,3 +2156,229 @@ fn a_pick_point_that_moves_a_hair_keeps_its_stretches_name_and_a_new_edge_keeps_
let errors: Vec<String> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
assert!(errors.is_empty(), "{errors:?}");
}
// `selection.get`: the selection read back, resolved, without changing anything.
fn close(v: &Value, want: f64) -> bool {
v.as_f64().is_some_and(|x| (x - want).abs() < 1e-6)
}
fn close3(v: &Value, want: [f64; 3]) -> bool {
v.as_array().is_some_and(|a| a.len() == 3 && a.iter().zip(want).all(|(x, w)| close(x, w)))
}
/// A 40 × 30 × 20 box with a Ø10 hole through it at (20, 15).
fn holed_box(s: &mut Session) -> String {
run(s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(s, "solid.cylinder", json!({"radius": 5, "height": 22, "base": [20, 15, -1], "operation": "cut"}));
s.model.state().bodies.first().unwrap().name.clone()
}
/// The face of the first body whose surface satisfies `want`, as a pick.
fn face_pick(s: &Session, want: impl Fn(solidcraft_kernel::FaceSurface) -> bool) -> Value {
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
let f = (0..n).find(|&f| solidcraft_kernel::face_surface(&b.body, f).is_some_and(&want)).expect("face");
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0];
json!({"type": "face", "body": b.name, "index": f, "point": p})
}
/// The mesh edge all of whose points satisfy `on`, as a pick.
fn edge_pick(s: &Session, on: impl Fn(Vec3) -> bool) -> Value {
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let i = m.edges.iter().position(|e| e.iter().all(|p| on(*p))).expect("edge");
let e = &m.edges[i];
json!({"type": "edge", "body": b.name, "index": i, "point": e[e.len() / 2]})
}
#[test]
fn empty_selection_is_an_empty_list() {
let mut s = Session::default();
assert_eq!(run(&mut s, "selection.get", json!({})), json!({"count": 0, "items": []}));
holed_box(&mut s);
assert_eq!(run(&mut s, "selection.get", json!({"detail": false})), json!({"count": 0, "items": []}));
}
#[test]
fn faces_edges_and_bodies_are_resolved() {
let mut s = Session::default();
let body = holed_box(&mut s);
let top = face_pick(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Plane { normal, .. } if normal.z > 0.99));
let hole = face_pick(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { .. }));
// The top front edge: y = 0, z = 20, along X.
let line = edge_pick(&s, |p| p.y.abs() < 1e-9 && (p.z - 20.0).abs() < 1e-9);
// The hole's rim on the top face.
let rim = edge_pick(&s, |p| (p.z - 20.0).abs() < 1e-9 && ((p.x - 20.0).hypot(p.y - 15.0) - 5.0).abs() < 1e-6);
let items = json!([top, hole, line, rim, {"type": "body", "name": body}]);
run(&mut s, "select.set", json!({ "items": items }));
let revision = s.revision;
let (undo, dirty, doc) = (s.undo.len(), s.is_dirty(), s.doc.clone());
let r = run(&mut s, "selection.get", json!({}));
assert_eq!(r["count"], 5, "{r}");
let it = r["items"].as_array().unwrap();
let top = &it[0];
assert_eq!(top["kind"], "face");
assert_eq!(top["type"], "face", "items keep the select.set form");
assert_eq!(top["body"], json!(body));
assert_eq!(top["surface"]["type"], "plane", "{top}");
assert!(close3(&top["surface"]["normal"], [0.0, 0.0, 1.0]), "{top}");
let area = 40.0 * 30.0 - std::f64::consts::PI * 25.0;
assert!(top["area_mm2"].as_f64().is_some_and(|a| (a - area).abs() < area * 1e-3), "{top}");
assert!(top["name"].as_str().is_some_and(|n| !n.is_empty()), "persistent face name: {top}");
assert!(top["name_exact"].is_boolean());
let hole = &it[1];
assert_eq!(hole["surface"]["type"], "cylinder", "{hole}");
assert!(close(&hole["surface"]["radius"], 5.0), "{hole}");
assert!(hole["surface"]["axis"].as_array().is_some_and(|a| a[2].as_f64().is_some_and(|z| z.abs() > 0.999)), "{hole}");
assert!(close(&hole["diameter"], 10.0), "{hole}");
let line = &it[2];
assert_eq!(line["kind"], "edge");
assert_eq!(line["curve"], "line", "{line}");
assert!(close(&line["length_mm"], 40.0), "{line}");
let ends: Vec<f64> = [&line["start"], &line["end"]].iter().map(|p| p[0].as_f64().unwrap()).collect();
assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 40.0).abs() < 1e-9), "{line}");
assert!(line["name"].as_str().is_some_and(|n| n.contains('|')), "edge names join their faces' names: {line}");
let rim = &it[3];
assert_eq!(rim["curve"], "circle", "{rim}");
assert!(close(&rim["radius"], 5.0), "{rim}");
assert!(close3(&rim["center"], [20.0, 15.0, 20.0]), "{rim}");
let b = &it[4];
assert_eq!(b["kind"], "body");
assert_eq!(b["name"], json!(body));
let v = 40.0 * 30.0 * 20.0 - std::f64::consts::PI * 25.0 * 20.0;
assert!(b["volume_mm3"].as_f64().is_some_and(|x| (x - v).abs() < v * 1e-2), "{b}");
assert!(b["feature"]["name"].is_string(), "{b}");
assert_eq!(b["visible"], true);
assert!(b.get("component").is_none(), "no component outside an assembly");
// Read-only: nothing changed.
assert_eq!(s.revision, revision);
assert_eq!(s.undo.len(), undo);
assert_eq!(s.is_dirty(), dirty);
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
assert_eq!(s.selection.len(), 5);
// Without detail: identity only.
let r = run(&mut s, "selection.get", json!({"detail": false}));
let first = r["items"][0].as_object().unwrap();
assert!(first.contains_key("index") && first.contains_key("point") && !first.contains_key("surface"), "{r}");
// The items go straight back into select.set.
let items: Vec<Value> = r["items"].as_array().unwrap().clone();
run(&mut s, "select.clear", json!({}));
run(&mut s, "select.set", json!({ "items": items }));
assert_eq!(s.selection.len(), 5);
}
#[test]
fn sketch_entities_name_their_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 7}));
let curve = r["created"]["curves"][0].as_str().unwrap().to_string();
run(&mut s, "select.set", json!({"items": [{"type": "sketch_curve", "id": curve}]}));
let r = run(&mut s, "selection.get", json!({}));
let it = &r["items"][0];
assert_eq!(it["kind"], "sketch_curve");
assert_eq!(it["entity"], "circle", "{it}");
assert!(close(&it["radius"], 7.0), "{it}");
assert_eq!(it["sketch"], json!(s.active_sketch.unwrap()), "{it}");
assert!(it["sketch_name"].is_string());
// What it returns goes back in as it is.
run(&mut s, "select.set", json!({"items": [it.clone()]}));
assert_eq!(run(&mut s, "selection.get", json!({}))["items"][0], *it);
// A curve picked into a dialog names its sketch; the item keeps it.
let sketch = s.active_sketch.unwrap();
run(&mut s, "select.set", json!({"items": [{"type": "sketch_curve", "id": curve, "sketch": sketch}]}));
let r = run(&mut s, "selection.get", json!({}));
let it = &r["items"][0];
assert_eq!((&it["sketch"], &it["entity"]), (&json!(sketch), &json!("circle")), "{it}");
// A line's midpoint: named by its line, reported where it is.
let r = run(&mut s, "sketch.line", json!({"points": [[2, 4], [12, 10]]}));
let line = r["created"]["curves"][0].as_str().unwrap().to_string();
run(&mut s, "select.set", json!({"items": [{"type": "sketch_midpoint", "id": line}]}));
let r = run(&mut s, "selection.get", json!({}));
let it = &r["items"][0];
assert_eq!(it["kind"], "sketch_midpoint", "{it}");
assert_eq!(it["id"], json!(line), "{it}");
assert_eq!(it["entity"], "midpoint", "{it}");
assert!(close3(&it["position"], [7.0, 7.0, 0.0]), "{it}");
assert_eq!(it["sketch"], json!(s.active_sketch.unwrap()), "{it}");
}
#[test]
fn construction_points_report_their_position() {
let mut s = Session::default();
run(&mut s, "construct.point.vertex", json!({"point": {"point": [15, 6, 7]}, "name": "Pin"}));
run(&mut s, "select.set", json!({"items": [{"type": "construction_point", "name": "Pin"}]}));
let r = run(&mut s, "selection.get", json!({}));
let it = &r["items"][0];
assert_eq!(it["kind"], "construction_point", "{it}");
assert_eq!(it["name"], "Pin", "{it}");
assert!(close3(&it["position"], [15.0, 6.0, 7.0]), "{it}");
}
#[test]
fn assembly_bodies_report_their_component_and_placement() {
let mut s = Session::default();
let created = run(&mut s, "component.create", json!({"name": "Part"}));
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length": 2, "width": 3, "height": 4, "body_name": "Block", "operation": "new"}));
let second = run(&mut s, "occurrence.copy", json!({"occurrence": first}))["occurrence"].as_u64().unwrap();
let name = s.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
run(&mut s, "select.set", json!({"items": [{"type": "body", "name": name}, {"type": "occurrence", "path": [second]}]}));
let r = run(&mut s, "selection.get", json!({}));
let b = &r["items"][0];
assert_eq!(b["component"]["id"], component, "{b}");
assert_eq!(b["component"]["name"], "Part", "{b}");
assert_eq!(b["occurrence_path"], json!([second]), "{b}");
assert_eq!(b["source_body"], "Block", "{b}");
let o = &r["items"][1];
assert_eq!(o["kind"], "occurrence");
assert_eq!(o["component"]["id"], component, "{o}");
assert_eq!(o["bodies"], json!([name]), "{o}");
}
#[test]
fn hostile_params_and_stale_selections_never_fail_badly() {
let mut s = Session::default();
let body = holed_box(&mut s);
// `select.set` refuses a body that isn't there (#9737), so `selection.get` never sees one.
let e = s.execute("select.set", &json!({"items": [{"type": "edge", "body": "nope", "index": 0, "point": [0, 0, 0]}]}));
assert!(matches!(e, Err(EngineError::BadParams { .. })), "{e:?}");
// Items that point at nothing still come back, by identity.
let stale = json!([
{"type": "face", "body": body, "index": 999_999, "point": [1e300, -1e300, 0]},
{"type": "edge", "body": body, "index": usize::MAX, "point": [0, 0, 0]},
{"type": "vertex", "body": body, "point": [0, 0, 0]},
{"type": "feature", "id": u64::MAX},
{"type": "sketch_curve", "id": "zz"},
{"type": "sketch_point", "id": "99999999999999999999999:p"},
{"type": "sketch_constraint", "id": ""},
{"type": "profile", "sketch": 12345, "index": 3},
{"type": "plane", "name": "XY"},
{"type": "axis", "name": "Q"},
]);
run(&mut s, "select.set", json!({ "items": stale }));
let r = run(&mut s, "selection.get", json!({}));
assert_eq!(r["count"], 10, "{r}");
for v in [json!(1), json!("yes"), json!([true]), json!({"a": 1})] {
let e = s.execute("selection.get", &json!({ "detail": v })).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{e}");
}
for v in [json!(null), json!([]), json!("x"), json!(f64::MAX), json!({"detail": null})] {
assert!(s.execute("selection.get", &v).is_ok(), "{v}");
}
}
+7 -1
View File
@@ -163,7 +163,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|rp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); rp \"Rp 1/2\" (ISO 7-1's parallel internal thread, 55°, an R screws into it; internal only); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its nominal diameter (a 12 mm hole is M12) or else the size whose tap drill it is); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); old_auto_size?: bool (an automatic size keeps the rule of designs saved before a hole was sized by its nominal diameter: a 12 mm hole is M14; set on those threads, keeps them as they were when edited); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, after the groove is cut, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a hole as wide as the thread or wider is narrowed to the thread's minor diameter first, and the reply's warning says so; a shaft thicker than the thread is refused with the size to make it, never turned down"),
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|rp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); rp \"Rp 1/2\" (ISO 7-1's parallel internal thread, 55°, an R screws into it; internal only); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its nominal diameter (a 12 mm hole is M12) or else the size whose tap drill it is); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); old_auto_size?: bool (an automatic size keeps the rule of designs saved before a hole was sized by its nominal diameter: a 12 mm hole is M14; set on those threads, keeps them as they were when edited); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false); continuous_flanks?: bool (default true; false keeps the historical half-turn faces and groove-before-chamfer construction). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a hole as wide as the thread or wider is narrowed to the thread's minor diameter first, and the reply's warning says so; a shaft thicker than the thread is refused with the size to make it, never turned down"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
@@ -3884,11 +3884,17 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, msg));
}
let designation_is_none = designation.is_none();
let continuous_flanks = match p.get("continuous_flanks") {
None => true,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(bad(cmd, "`continuous_flanks` must be a boolean")),
};
let kind = FeatureKind::Thread {
face: FacePick(face),
designation,
length,
modeled,
continuous_flanks,
from,
up_to,
form,
+1
View File
@@ -424,6 +424,7 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
angle: 0.0,
suppressed: false,
exprs: Default::default(),
captured: None,
})
})
.collect::<Result<_>>()?;
+41
View File
@@ -1368,6 +1368,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
angle: angle.unwrap_or(0.0),
suppressed: false,
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
captured: None,
};
// No angle given: B keeps its turn about the joint's axis where a half turn would otherwise
// come only from how its origin's x was chosen. (An angle relation's angle is its tilt.)
@@ -1413,6 +1414,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
angle: 0.0,
suppressed: false,
exprs: Default::default(),
captured: None,
};
add_joint(s, j, p)
}
@@ -2315,6 +2317,45 @@ mod reach_tests;
#[cfg(test)]
#[path = "joints_relations_tests.rs"]
mod relations_tests;
/// Capture Position: every moving joint keeps its current values (as the solver places them) as
/// its captured position. Returns how many joints were captured.
pub(in crate::cmd) fn capture_values(s: &mut Session) -> usize {
let current = joints::joint_values(&s.doc.assembly);
if s.doc.assembly.joints.iter().all(|j| j.kind == JointKind::Rigid) {
return 0;
}
let mut n = 0;
for j in s.doc_mut().assembly.joints.iter_mut().filter(|j| j.kind != JointKind::Rigid) {
let mut q = current.get(&j.id).cloned().unwrap_or_else(|| j.values.clone());
q.resize(j.kind.dofs().len(), 0.0);
j.captured = Some(q);
n += 1;
}
n
}
/// Revert Position: every joint with a captured position goes back to it, clamped to its limits.
/// A joint with none (a design from before captured joint values) keeps its values. The values
/// put back drop their expressions, as `joint.drive` does. Returns how many joints went back.
pub(in crate::cmd) fn revert_values(s: &mut Session) -> usize {
if !s.doc.assembly.joints.iter().any(|j| j.captured.is_some() && j.kind != JointKind::Rigid) {
return 0;
}
let mut n = 0;
for j in s.doc_mut().assembly.joints.iter_mut().filter(|j| j.kind != JointKind::Rigid) {
let Some(mut q) = j.captured.clone() else { continue };
q.resize(j.kind.dofs().len(), 0.0);
joints::clamp(j, &mut q);
j.values = q;
for i in 0..j.kind.dofs().len() {
j.exprs.set_value(i, None);
}
n += 1;
}
resolve(s);
n
}
#[cfg(test)]
#[path = "joints_tests.rs"]
mod tests;
@@ -256,6 +256,7 @@ fn a_panel_full_of_parts_placed_by_relations_stays_solved() {
angle: 0.0,
suppressed: false,
exprs: Default::default(),
captured: None,
};
if let Some(q) = solidcraft_doc::joints::relation_values(&s.doc, &s.model.state(), &j) {
j.values = q;
+44
View File
@@ -191,6 +191,50 @@ fn slider_limits_clamp() {
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 19.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
}
#[test]
fn capture_position_saves_joint_values_and_revert_position_puts_them_back() {
// #14275: Capture Position also saves every joint's values; Revert Position puts them back.
let mut s = two_cubes();
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Rail"}));
let b = Vec3::new(25.0, 5.0, 0.0);
// Nothing captured yet: Revert Position leaves the joint where it is.
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 3}));
let r = run(&mut s, "component.revert_position", json!({}));
assert_eq!(r["joints"], json!(0), "{r}");
assert!(near(place(&s, "B", b), Vec3::new(5.0, 5.0, 13.0)));
// Capture at 3 mm, drive away, and revert.
let r = run(&mut s, "component.capture_position", json!({}));
assert_eq!(r["joints"], json!(1), "{r}");
assert_eq!(s.doc.assembly.joints[0].captured, Some(vec![3.0]));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 9}));
let r = run(&mut s, "component.revert_position", json!({}));
assert_eq!(r["joints"], json!(1), "{r}");
assert!(near(place(&s, "B", b), Vec3::new(5.0, 5.0, 13.0)));
// One undo step puts it back where it was driven.
run(&mut s, "edit.undo", json!({}));
assert!(near(place(&s, "B", b), Vec3::new(5.0, 5.0, 19.0)));
// The captured values are saved with the design.
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
*s.doc_mut() = doc;
run(&mut s, "joint.solve", json!({}));
run(&mut s, "component.revert_position", json!({}));
assert!(near(place(&s, "B", b), Vec3::new(5.0, 5.0, 13.0)));
}
#[test]
fn a_design_without_captured_joint_values_loads_unchanged() {
// A file saved before captured joint values has no `captured`: it loads with the same values,
// and a joint with none saves without the key.
let mut s = two_cubes();
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Rail"}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 4}));
let json = s.doc.to_json();
assert!(!json.contains("\"captured\""), "no captured key before Capture Position");
let doc = solidcraft_doc::Document::from_json(&json).unwrap();
assert_eq!(doc.assembly.joints[0].captured, None);
assert_eq!(doc.assembly.joints[0].values, s.doc.assembly.joints[0].values);
}
#[test]
fn rigid_cycles_solve_and_conflicts_report() {
let mut s = two_cubes();
+18 -1
View File
@@ -166,7 +166,7 @@ fn arc_points(center: Vec3, axis: UnitVec3, start: Vec3, angle: f64) -> Vec<Vec3
/// The solved sketch holding a picked curve or point: `sketch` when given, else the active
/// sketch, else the last sketch with that id.
fn sketch_of<'a>(
pub(super) fn sketch_of<'a>(
s: &Session,
st: &'a solidcraft_doc::ModelState,
sketch: Option<u64>,
@@ -489,6 +489,23 @@ pub(crate) fn shape_of(s: &Session, sel: &Sel) -> Option<(Shape, Vec<Vec3>, Vec<
})
}
/// What `selection.get` reports of a picked item beyond its identity: the measurement
/// `inspect.measure` gives it (length, area, volume, radius…), its direction where it has one
/// (a planar face's normal, a straight edge's direction) and, for an edge, whether it is a line,
/// a circle, an arc or another curve.
pub(super) fn resolve(s: &Session, st: &solidcraft_doc::ModelState, sel: &Sel) -> Option<(Value, Option<Vec3>)> {
let item = describe(s, st, sel)?;
let mut json = item.json;
if matches!(sel, Sel::Edge { .. }) {
json["curve"] = match item.shape {
Shape::Line { .. } => json!("line"),
Shape::Round { .. } => json.get("round").cloned().unwrap_or_else(|| json!("arc")),
_ => json!("other"),
};
}
Some((json, item.dir))
}
fn sample<T: Copy>(v: &[T], max: usize) -> Vec<T> {
let step = v.len().div_ceil(max).max(1);
v.iter().step_by(step).copied().collect()
+2
View File
@@ -89,6 +89,7 @@ mod parts;
mod point_cloud;
mod script;
mod section;
mod selection;
pub mod settings;
pub(crate) use clipboard::{component_path_form, copy_keys};
pub(crate) use component::matrix::{rigid as rigid_matrix, tolerance as matrix_tolerance};
@@ -364,6 +365,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(selection::COMMANDS.iter());
v.extend(clipboard::COMMANDS.iter());
v.extend(sketch_clipboard::COMMANDS.iter());
v.extend(script::COMMANDS.iter());
+278
View File
@@ -0,0 +1,278 @@
//! Reading the selection (`selection.get`): what is selected, resolved to what an agent or a
//! script needs to act on it, without changing the design, the selection or the undo history.
use std::collections::BTreeMap;
use serde_json::{Map, Value, json};
use solidcore_record::Vec3;
use solidcraft_geom::prelude::*;
use super::CommandSpec;
use super::measure_sel::{resolve, sketch_of};
use crate::params::bad;
use crate::{Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.get", "Get Selection", selection_get).icon("select").noundo().params(
"detail?: bool (default true: resolve each item's geometry) → {count, items: [{kind (= type: body|face|edge|vertex|feature|sketch_curve|\
sketch_point|sketch_midpoint|sketch_constraint|profile|plane|axis|construction_point|occurrence…), the selection's own fields as select.set takes them (body, index, point, \
id, sketch, path…), and with detail: face: name (persistent face name), name_exact, surface ({type: plane|cylinder|cone|sphere|torus|\
other, normal/point, origin/axis/radius…}), area_mm2, planar; edge: name (persistent edge name), curve (line|circle|arc|other), \
length_mm, start, end, center/axis/radius for round edges; body: volume_mm3, feature, visible; any body item: component {id, name} \
and occurrence_path in an assembly; sketch items: sketch (its id, as select.set takes it), sketch_name, entity (line|circle|arc|point|midpoint|…), position for points and midpoints}]}; read-only, [] when nothing is \
selected",
)];
/// Items resolved in full; the rest of a huge selection is listed by identity only.
const MAX_DETAIL: usize = 500;
fn selection_get(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "selection.get";
let detail = match p.get("detail") {
None | Some(Value::Null) => true,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`detail` must be true or false")),
};
let s: &Session = s;
let mut ctx = Context::new(s)?;
let items: Vec<Value> = s
.selection
.iter()
.enumerate()
.map(|(i, sel)| {
let mut v = identity(sel);
if detail && i < MAX_DETAIL {
ctx.add_detail(sel, &mut v);
}
Value::Object(v)
})
.collect();
let mut out = json!({"count": items.len(), "items": items});
if detail && s.selection.len() > MAX_DETAIL {
out["detail_limit"] = json!(MAX_DETAIL);
}
Ok(out)
}
/// The selection item as `select.set` takes it, with `kind` naming what it is.
fn identity(sel: &Sel) -> Map<String, Value> {
let mut v = match serde_json::to_value(sel) {
Ok(Value::Object(m)) => m,
_ => Map::new(),
};
if let Some(t) = v.get("type").cloned() {
v.insert("kind".into(), t);
}
v
}
/// What resolving items shares: each body's placement and edge names, worked out once.
struct Context<'a> {
s: &'a Session,
st: std::sync::Arc<solidcraft_doc::ModelState>,
/// World body name → (definition name, component, occurrence path); only in an assembly.
placements: Option<BTreeMap<String, (String, u64, Vec<u64>)>>,
edge_names: BTreeMap<String, Vec<String>>,
}
impl<'a> Context<'a> {
fn new(s: &'a Session) -> Result<Self> {
Ok(Context { s, st: s.world_state()?, placements: None, edge_names: BTreeMap::new() })
}
fn add_detail(&mut self, sel: &Sel, v: &mut Map<String, Value>) {
// The measurement `inspect.measure` gives the item, without its own `type`.
let mut dir = None;
if let Some((Value::Object(m), d)) = resolve(self.s, &self.st, sel) {
dir = d;
for (k, x) in m {
if k != "type" && !v.contains_key(&k) {
v.insert(k, x);
}
}
}
match sel {
Sel::Body { name } => {
self.body_info(name, v);
if let Some(b) = self.st.body(name) {
let feature = self.s.doc.feature(b.feature);
v.insert("feature".into(), json!({"id": b.feature, "name": feature.map(|f| f.name.clone())}));
}
v.insert("visible".into(), json!(self.s.body_visible(name)));
}
Sel::Face { body, index, .. } => {
self.body_info(body, v);
if let Some(b) = self.st.body(body) {
let names = solidcraft_doc::naming::face_names(b);
if let Some(n) = names.get(*index).filter(|n| !n.is_empty()) {
v.insert("name".into(), json!(n));
v.insert("name_exact".into(), json!(solidcraft_doc::naming::face_name_exact(b, *index)));
}
v.insert("surface".into(), surface(solidcraft_kernel::face_surface(&b.body, *index), dir));
}
}
Sel::Edge { body, index, .. } => {
self.body_info(body, v);
if let Some(b) = self.st.body(body) {
// Its ends from its exact curve; an intersection curve, which the record holds
// only as its surfaces, has none to give.
if let Ok(segs) = solidcraft_kernel::edge_path(&b.body, *index)
&& let (Some(a), Some(z)) = (segs.first(), segs.last())
{
v.insert("start".into(), json!(a.start()));
v.insert("end".into(), json!(z.end()));
}
let names = self.edge_names.entry(body.clone()).or_insert_with(|| solidcraft_doc::naming::edge_names(b));
if let Some(n) = names.get(*index).filter(|n| !n.is_empty()) {
v.insert("name".into(), json!(n));
}
if let Some(d) = dir {
v.insert("direction".into(), json!(d));
}
}
}
Sel::Vertex { body, .. } => self.body_info(body, v),
Sel::Feature { id } => {
if let Some(f) = self.s.doc.feature(*id) {
v.insert("name".into(), json!(f.name));
v.insert("feature_type".into(), json!(f.kind.type_name()));
v.insert("suppressed".into(), json!(f.suppressed));
}
}
Sel::SketchCurve { id, sketch } => self.sketch_entity(*sketch, id, Entity::Curve, v),
Sel::ExternalSketchCurve { sketch, id } => self.sketch_entity(Some(*sketch), id, Entity::Curve, v),
Sel::SketchPoint { id } => self.sketch_entity(None, id, Entity::Point, v),
// The midpoint of a line (`id` names the line): its sketch and where it is.
Sel::SketchMidpoint { id } => self.sketch_entity(None, id, Entity::Midpoint, v),
Sel::ExternalSketchPoint { sketch, id } => self.sketch_entity(Some(*sketch), id, Entity::Point, v),
Sel::SketchConstraint { id } => self.sketch_entity(None, id, Entity::Constraint, v),
Sel::Profile { sketch, .. } => {
if let Some(ss) = self.st.sketch(*sketch) {
v.insert("sketch".into(), json!(ss.feature));
v.insert("sketch_name".into(), json!(ss.name));
}
}
Sel::Occurrence { path } => {
if let Ok(o) = self.s.occurrence_placement(path) {
v.insert("name".into(), json!(o.name));
v.insert("component".into(), self.component(o.component));
v.insert("grounded".into(), json!(o.grounded));
v.insert("bodies".into(), json!(o.bodies.iter().map(|b| &b.world).collect::<Vec<_>>()));
}
}
// What these are (origin, direction, position) comes from the measurement above.
Sel::Plane { .. } | Sel::Axis { .. } | Sel::ConstructionPoint { .. } => {}
}
}
/// A body item's component and occurrence path (in an assembly), and its definition's name
/// when it is a further placement of it.
fn body_info(&mut self, body: &str, v: &mut Map<String, Value>) {
if self.s.doc.occurrences.is_empty() {
return;
}
let s = self.s;
let placements = self.placements.get_or_insert_with(|| {
s.doc
.body_frames_with_source(&s.model.state())
.into_iter()
.map(|(world, source, component, path, _)| (world, (source, component, path)))
.collect()
});
let Some((source, component, path)) = placements.get(body).cloned() else { return };
if source != body {
v.insert("source_body".into(), json!(source));
}
if !path.is_empty() {
v.insert("occurrence_path".into(), json!(path));
}
v.insert("component".into(), self.component(component));
}
fn component(&self, id: u64) -> Value {
let name = if id == 0 { Some(self.s.doc.name.clone()) } else { self.s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()) };
json!({"id": id, "name": name})
}
/// A sketch curve, point, line midpoint or constraint: its sketch and what kind of entity it is.
fn sketch_entity(&self, sketch: Option<u64>, id: &str, entity: Entity, v: &mut Map<String, Value>) {
// "<sketch id>:<point id>" names a point of any sketch.
let (sketch, id) = match (entity, id.split_once(':').and_then(|(k, p)| Some((k.parse::<u64>().ok()?, p)))) {
(Entity::Point, Some((k, p))) if sketch.is_none() => (Some(k), p),
_ => (sketch, id),
};
let found = match entity {
Entity::Curve | Entity::Midpoint => sketch_of(self.s, &self.st, sketch, |sk| sk.curve_index(id).is_some()),
Entity::Point => sketch_of(self.s, &self.st, sketch, |sk| sk.point_index(id).is_some()),
Entity::Constraint => sketch_of(self.s, &self.st, sketch, |sk| sk.constraints.iter().any(|c| c.id == id)),
};
let Some(ss) = found else { return };
v.insert("sketch".into(), json!(ss.feature));
v.insert("sketch_name".into(), json!(ss.name));
let sk = &ss.sketch;
match entity {
Entity::Curve => {
if let Some(c) = sk.curve_index(id).and_then(|i| sk.curves.get(i)) {
v.insert("entity".into(), kind_name(serde_json::to_value(&c.kind).ok()));
v.insert("construction".into(), json!(c.construction));
}
}
Entity::Point => {
if let Some(pt) = sk.point_index(id).and_then(|i| sk.points.get(i)) {
v.insert("entity".into(), json!("point"));
v.entry("position").or_insert_with(|| json!(ss.plane.to_world(pt.pos)));
}
}
Entity::Midpoint => {
v.insert("entity".into(), json!("midpoint"));
let line = sk.curve_index(id).and_then(|i| sk.curves.get(i)).and_then(|c| match c.kind {
solidcraft_sketch::CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)),
_ => None,
});
if let Some((a, b)) = line {
v.entry("position").or_insert_with(|| json!(ss.plane.to_world(a.lerp(b, 0.5))));
}
}
Entity::Constraint => {
if let Some(c) = sk.constraints.iter().find(|c| c.id == id) {
v.insert("entity".into(), kind_name(serde_json::to_value(c).ok()));
v.insert("driven".into(), json!(c.driven));
if let Some(param) = &c.param {
v.insert("parameter".into(), json!(param));
}
}
}
}
}
}
#[derive(Clone, Copy)]
enum Entity {
Curve,
Point,
Midpoint,
Constraint,
}
/// The `type` tag of a serialized sketch curve or constraint.
fn kind_name(v: Option<Value>) -> Value {
v.and_then(|v| v.get("type").cloned()).unwrap_or_else(|| json!("other"))
}
/// A face's surface: an analytic one with its defining values, else a plane when the face is
/// flat (`normal` from its triangles), else `other`.
fn surface(s: Option<solidcraft_kernel::FaceSurface>, planar: Option<Vec3>) -> Value {
use solidcraft_kernel::FaceSurface as F;
match s {
Some(F::Plane { normal, point }) => json!({"type": "plane", "normal": normal, "point": point}),
Some(F::Cylinder { origin, axis, radius }) => json!({"type": "cylinder", "origin": origin, "axis": axis, "radius": radius}),
Some(F::Cone { apex, axis, half_angle }) => json!({"type": "cone", "apex": apex, "axis": axis, "half_angle_deg": half_angle.to_degrees()}),
Some(F::Sphere { center, radius }) => json!({"type": "sphere", "center": center, "radius": radius}),
Some(F::Torus { center, axis, major, minor }) => {
json!({"type": "torus", "center": center, "axis": axis, "major_radius": major, "minor_radius": minor})
}
None => match planar {
Some(n) => json!({"type": "plane", "normal": n}),
None => json!({"type": "other"}),
},
}
}
+5 -1
View File
@@ -20,6 +20,7 @@ use crate::schema::surface::{
UnstitchParams, UntrimParams,
};
use crate::schema::surface_blend::BlendParams;
use crate::schema::surface_ruled::SurfaceRuledParams;
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
@@ -35,6 +36,7 @@ const SURFACE_TRIM_CONTRACT: TypedContract<SurfaceTrimParams> = TypedContract::n
const SURFACE_SWEEP_CONTRACT: TypedContract<SurfaceSweepParams> = TypedContract::new();
const SURFACE_LOFT_CONTRACT: TypedContract<SurfaceLoftParams> = TypedContract::new();
const BLEND_CONTRACT: TypedContract<BlendParams> = TypedContract::new();
const SURFACE_RULED_CONTRACT: TypedContract<SurfaceRuledParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
@@ -91,9 +93,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&OFFSET_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"),
CommandSpec::new("surface.ruled", "Ruled Surface", ruled)
CommandSpec::new("surface.ruled", "Ruled Surface", crate::checked!("surface.ruled", ruled))
.at("SURFACE", "CREATE")
.icon("surface_ruled")
.contract(&SURFACE_RULED_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back); far?: [null|[[x,y,z]]] (per chain edge, the interior control points of a free strip's far curve, as the default curve's handles list them; null: the default). A constant ruling, or one keeping its angle to an arc's axis, gives an exact plane, cylinder or cone; where the rulings turn freely along an edge, the strip is the exact ruled surface to a far curve of its own (from corner to corner, fair, near the rulings' ends). A new surface body"),
CommandSpec::new("surface.blend", "Blend Surface", crate::checked!("surface.blend", blend))
.at("SURFACE", "CREATE")
+4 -1
View File
@@ -355,7 +355,10 @@ impl Ahead {
let stop = self.0.stop.clone();
stop.within(|| {
let (after, _) = self.0.preview_in_place(commands, Some(step))?;
after.world_state()?.warm_display();
// Stopped by its watcher: nothing it ends with will be shown.
if !solidcraft_doc::progress::cancelled() {
after.world_state()?.warm_display();
}
Ok(Evaluated(after.model))
})
}
+11 -4
View File
@@ -1462,8 +1462,9 @@ fn sketch_names_schema(_: &mut SchemaGenerator) -> Schema {
})
}
/// Make every pending occurrence move the occurrence's placement. The exploded view is shown,
/// never captured: it stays shown, and the design keeps its placements.
/// Make every pending occurrence move the occurrence's placement, and save every moving joint's
/// current values for Revert Position. The exploded view is shown, never captured: it stays
/// shown, and the design keeps its placements.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct CapturePositionParams {}
@@ -1473,13 +1474,16 @@ pub struct CapturePositionParams {}
pub struct CapturePositionResult {
/// How many pending moves became the occurrences' placements.
pub captured: usize,
/// How many joints had their current values saved, for Revert Position to put back.
pub joints: usize,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Drop pending occurrence moves, putting the occurrences back where they were captured. Not an
/// undo step.
/// Drop pending occurrence moves, putting the occurrences back where they were captured. Without
/// `occurrences`, the joints also go back to the values Capture Position saved, clamped to their
/// limits. One undo step, not a history step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RevertPositionParams {
@@ -1494,6 +1498,9 @@ pub struct RevertPositionParams {
pub struct RevertPositionResult {
/// How many pending moves were dropped.
pub reverted: usize,
/// How many joints went back to the values Capture Position saved (clamped to their
/// limits). Only when every occurrence is reverted (no `occurrences`).
pub joints: usize,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
+1
View File
@@ -137,6 +137,7 @@ pub mod surface_analysis;
pub mod surface_blend;
pub mod surface_boundary;
pub mod surface_match;
pub mod surface_ruled;
pub mod timeline_clipboard;
pub mod ui_prefs;
pub mod view_navigation;
+211
View File
@@ -0,0 +1,211 @@
//! Contract for Ruled Surface (`surface.ruled`) in `cmd/surface.rs` (#13134). It replies with the
//! plain feature reply, [`super::features::FeatureResult`].
//!
//! The handler reads `angle` only in angle mode and `direction` only in angle and direction
//! modes, and says when either is missing there. The contract checks their form whenever they are
//! given and leaves which mode needs them to the handler. Whether the body exists, the points lie
//! on a chain of its edges and on a face, and the expressions evaluate are the handler's to check.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use solidcore_record::Vec3;
// `Vec3Geom::len`, which a direction's check measures as the handler does.
use solidcraft_geom::prelude::*;
use super::check::whole;
use super::types::{AngleExpr, FeatureCommon, LengthExpr, Point3, RuledCornersParam, RuledModeParam, feature_common};
use crate::params;
/// Make a surface body: a strip that reaches `distance` out from a chain of a body's edges, along
/// rulings set by `mode`. A constant ruling, or one keeping its angle to an arc's axis, gives an
/// exact plane, cylinder or cone; where the rulings turn freely along an edge, the strip is the
/// exact ruled surface to a far curve of its own.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SurfaceRuledParams {
/// The body whose edges the strip starts from, by name.
#[serde(deserialize_with = "body")]
pub body: String,
pub edges: RuledEdges,
/// How far the strip reaches from the edges.
pub distance: LengthExpr,
#[serde(default)]
pub mode: Option<RuledModeParam>,
/// Angle mode: the rulings' angle from `direction`, else from the face's normal, turned away
/// from the face (90 deg from the normal is tangent). Required in angle mode; read in no
/// other.
#[serde(default)]
pub angle: Option<AngleExpr>,
#[serde(default)]
pub direction: Option<RuledDirection>,
#[serde(default)]
pub face: Option<RuledFace>,
/// Make the strip on the other side. Default: false.
#[serde(default)]
pub flip: Option<bool>,
#[serde(default)]
pub corners: Option<RuledCornersParam>,
#[serde(default)]
pub far: Option<RuledFar>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// The handler's message for `body` when it isn't text.
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`body` is required"))),
}
}
/// The handler's message for `edges` it can't read: not a list, no points, or over 10,000.
const EDGES_MESSAGE: &str = "`edges` must list points on a chain of the body's edges";
/// The handler's message for `far` it can't read.
const FAR_MESSAGE: &str = "`far` must be a list, per edge, of null or [[x, y, z], ...]";
/// Points on a chain of the body's edges, 1 to 10,000, as `params::vec3` reads each. The handler
/// drops an item that isn't a point and takes the rest; the contract refuses it, with the
/// handler's message for a list it can't read.
#[derive(Clone, Debug, PartialEq)]
pub struct RuledEdges(pub Vec<Vec3>);
impl<'de> Deserialize<'de> for RuledEdges {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let points = v.as_array().and_then(|items| items.iter().map(params::vec3).collect::<Option<Vec<_>>>());
match points {
Some(points) if !points.is_empty() && points.len() <= 10_000 => Ok(RuledEdges(points)),
_ => Err(D::Error::custom(whole(EDGES_MESSAGE))),
}
}
}
impl JsonSchema for RuledEdges {
fn schema_name() -> Cow<'static, str> {
"RuledEdges".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "Points in mm on a chain of the body's edges, open or closed: 1 to 10,000.",
"type": "array",
"items": point,
"minItems": 1,
"maxItems": 10000
})
}
}
/// The rulings' direction in direction mode, or the reference the angle is measured from in angle
/// mode (a pull direction for a draft): not zero. Read in those modes only. The schema can't say
/// "not zero", so a zero direction matches it and the check refuses it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RuledDirection(pub Vec3);
impl<'de> Deserialize<'de> for RuledDirection {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
// As the handler reads it: a point whose length is over 1e-12.
params::vec3(&Value::deserialize(d)?)
.filter(|v| v.len() > 1e-12)
.map(RuledDirection)
.ok_or_else(|| D::Error::custom(whole("`direction` must be a non-zero [x, y, z]")))
}
}
impl JsonSchema for RuledDirection {
fn schema_name() -> Cow<'static, str> {
"RuledDirection".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = generator.subschema_for::<Point3>();
schema.insert(
"description".into(),
"Direction mode: the rulings' direction; angle mode: the reference the angle is measured from (a pull direction for a draft). Not zero. Required in direction mode; read in no other mode but angle.".into(),
);
schema
}
}
/// A point on the face the rulings' directions come from.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RuledFace(pub Vec3);
impl<'de> Deserialize<'de> for RuledFace {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
params::vec3(&Value::deserialize(d)?).map(RuledFace).ok_or_else(|| D::Error::custom(whole("`face` must be [x, y, z] on a face")))
}
}
impl JsonSchema for RuledFace {
fn schema_name() -> Cow<'static, str> {
"RuledFace".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = generator.subschema_for::<Point3>();
schema.insert(
"description".into(),
"A point in mm on the face the rulings' directions come from. Default: the face beside the most picked edges.".into(),
);
schema
}
}
/// A free strip's far-curve handles, per chain edge in order: `null` for the default curve, or the
/// interior control points as the default curve's handles list them. At most 10,000 edges and
/// 100,000 points each. Read in order, as the handler reads it, so the first item it can't read
/// gives the message.
#[derive(Clone, Debug, PartialEq)]
pub struct RuledFar(pub Vec<Option<Vec<Vec3>>>);
impl<'de> Deserialize<'de> for RuledFar {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let lists = v.as_array().filter(|a| a.len() <= 10_000).ok_or_else(|| D::Error::custom(whole(FAR_MESSAGE)))?;
let mut far = Vec::with_capacity(lists.len());
for list in lists {
far.push(match list {
Value::Null => None,
Value::Array(points) if points.len() <= 100_000 => Some(
points
.iter()
.map(params::vec3)
.collect::<Option<Vec<_>>>()
.ok_or_else(|| D::Error::custom(whole("`far` handles must be [x, y, z]")))?,
),
_ => return Err(D::Error::custom(whole(FAR_MESSAGE))),
});
}
Ok(RuledFar(far))
}
}
impl JsonSchema for RuledFar {
fn schema_name() -> Cow<'static, str> {
"RuledFar".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "A free strip's far-curve handles, per chain edge in order: null for the default curve, or the interior control points in mm, as the default curve's handles list them.",
"type": "array",
"maxItems": 10000,
"items": {"anyOf": [{"type": "null"}, {"type": "array", "maxItems": 100000, "items": point}]}
})
}
}
#[cfg(test)]
#[path = "surface_ruled_tests.rs"]
mod tests;
@@ -0,0 +1,188 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::SurfaceRuledParams;
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<SurfaceRuledParams>(),
&[
"angle",
"body",
"body_name",
"body_names",
"corners",
"direction",
"distance",
"edges",
"face",
"far",
"flip",
"mode",
"name",
"participants",
],
);
}
/// Points on the four top edges of [`boxed`]'s `Box`, a chain round its top.
fn rim() -> Value {
json!([[5, 0, 10], [10, 5, 10], [5, 10, 10], [0, 5, 10]])
}
/// A point on `Box`'s top face.
fn top() -> Value {
json!([5, 5, 10])
}
/// A 10 mm cube `Box` from the origin, at `level`.
fn boxed(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"})).unwrap_or_else(|e| panic!("{e}"));
s
}
/// Calls that fail today give the same message in strict, each on a fresh [`boxed`]: what the
/// contract checks (the body, the edges, the keywords, the direction, the face and the far
/// handles) and what it leaves to the handler (a body the design lacks, points off its edges or
/// faces, a distance or angle that doesn't evaluate or has the wrong unit, a zero distance, and
/// the angle or direction a mode needs). Left out: a missing `edges`, and a `distance` that isn't
/// a number or text, whose messages differ on purpose, as for every converted command.
#[test]
fn strict_keeps_todays_errors() {
let with = |extra: Value| {
let mut p = json!({"body": "Box", "edges": rim(), "distance": 2});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
};
let cases = [
json!({}),
json!({"body": 5, "edges": rim(), "distance": 2}),
json!({"body": null, "edges": rim(), "distance": 2}),
json!({"body": "Nope", "edges": rim(), "distance": 2}),
with(json!({"edges": 5})),
with(json!({"edges": []})),
with(json!({"edges": null})),
with(json!({"edges": ["x"]})),
with(json!({"edges": vec![[5, 0, 10]; 10_001]})),
with(json!({"edges": [[500, 500, 500]]})),
json!({"body": "Box", "edges": rim()}),
with(json!({"distance": "nonsense"})),
with(json!({"distance": "30 deg"})),
with(json!({"distance": 0})),
with(json!({"mode": "sideways"})),
with(json!({"mode": "SIDEWAYS"})),
with(json!({"mode": "angle"})),
with(json!({"mode": "angle", "angle": null})),
with(json!({"mode": "angle", "angle": "5 mm"})),
with(json!({"mode": "angle", "angle": 45, "direction": [1, 2]})),
with(json!({"mode": "direction"})),
with(json!({"mode": "direction", "direction": [0, 0, 0]})),
with(json!({"mode": "direction", "direction": "up"})),
with(json!({"corners": "square"})),
with(json!({"face": "top"})),
with(json!({"far": 5})),
with(json!({"far": [5]})),
with(json!({"far": [["x"]]})),
with(json!({"far": [null, [[1, 2]]]})),
];
for p in cases {
let error = |level: ParamCheck| boxed(level).execute("surface.ruled", &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
}
/// #13134: the contract takes what its input schema allows: each call's check finds no issue
/// exactly when the call matches the schema. A call in angle mode without `angle` matches it; the
/// handler refuses it. The one place the schema can't follow the check is a zero `direction`,
/// which it can't say isn't allowed; a last call shows it.
#[test]
fn the_check_takes_what_the_schema_allows() {
let schema = input_schema_for::<SurfaceRuledParams>();
let schema = schema.as_value();
let p = |extra: Value| {
let mut p = json!({"body": "B", "edges": [[1, 2, 3]], "distance": 2});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
};
for (p, takes) in [
(p(json!({})), true),
(
p(
json!({"edges": [{"x": 1, "y": 2, "z": 3}], "distance": "2 mm", "mode": "tangent", "face": [1, 2, 3], "flip": true, "corners": "round", "name": "S", "body_name": "Strip"}),
),
true,
),
(p(json!({"mode": "angle", "angle": "45 deg", "direction": [0, 0, 1]})), true),
(p(json!({"mode": "direction", "direction": {"x": 0, "y": -1, "z": 0}, "corners": "miter"})), true),
(p(json!({"far": [null, [[1, 2, 3], [4, 5, 6]], []], "body_names": ["Strip"], "participants": ["*"]})), true),
(p(json!({"mode": null, "angle": null, "direction": null, "face": null, "flip": null, "corners": null, "far": null})), true),
(p(json!({"mode": "angle"})), true),
(json!({"edges": [[1, 2, 3]], "distance": 2}), false),
(p(json!({"body": 1})), false),
(p(json!({"edges": []})), false),
(p(json!({"edges": 5})), false),
(p(json!({"edges": [[1, 2]]})), false),
(p(json!({"edges": [[1, 2, 3], "x"]})), false),
(json!({"body": "B", "edges": [[1, 2, 3]]}), false),
(p(json!({"distance": true})), false),
(p(json!({"mode": "sideways"})), false),
(p(json!({"mode": 3})), false),
(p(json!({"angle": true})), false),
(p(json!({"direction": [1, 2]})), false),
(p(json!({"direction": "up"})), false),
(p(json!({"face": "top"})), false),
(p(json!({"flip": "yes"})), false),
(p(json!({"corners": "square"})), false),
(p(json!({"far": 5})), false),
(p(json!({"far": [5]})), false),
(p(json!({"far": [["x"]]})), false),
(p(json!({"plane": "XY"})), false),
] {
let issues = crate::schema::issues("surface.ruled", &p);
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
let zero = p(json!({"mode": "direction", "direction": [0, 0, 0]}));
assert!(!crate::schema::issues("surface.ruled", &zero).is_empty() && conforms(schema, schema, &zero), "{zero}");
}
/// #13134: Ruled Surface replies with `FeatureResult`, with the keys it gave before, in each mode
/// and with each optional key: in Warn, without warnings, so its contract takes each call. Each
/// call makes one surface body and changes no other.
#[test]
fn ruled_surface_replies_with_feature_result() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
let calls = [
json!({"body": "Box", "edges": rim(), "distance": "2 mm", "mode": "tangent", "face": top(), "body_name": "Flange"}),
json!({"body": "Box", "edges": rim(), "distance": 2, "face": top()}),
json!({"body": "Box", "edges": rim(), "distance": 2, "mode": "ANGLE", "angle": "45 deg", "corners": "round", "face": top(), "name": "Draft"}),
json!({"body": "Box", "edges": [[5, 0, 10]], "distance": 2, "mode": "direction", "direction": [0, -1, 0], "body_name": "Lip"}),
json!({"body": "Box", "edges": rim(), "distance": 2, "face": top(), "flip": true, "corners": "miter", "far": [null, null, null, null]}),
json!({"body": "Box", "edges": rim(), "distance": 2, "face": top(), "mode": null, "angle": null, "direction": null, "body_names": ["Rim"]}),
];
for p in calls {
let mut s = boxed(ParamCheck::Warn);
let r = s.execute("surface.ruled", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, before, "{p}: {r}");
let count = |key: &str| r[key].as_array().map_or(usize::MAX, Vec::len);
assert_eq!((count("created_bodies"), count("changed_bodies"), count("removed_bodies")), (1, 0, 0), "{p}: {r}");
}
}
+16
View File
@@ -535,6 +535,22 @@ keyword_param!(
"emboss or deboss",
"Whether the profiles are raised from the face (emboss, the default) or sunk into it (deboss). Any case."
);
keyword_param!(
/// `surface.ruled`'s `mode`.
RuledModeParam,
"mode",
&["normal", "tangent", "angle", "direction"],
"normal, tangent, angle or direction",
"Which way the rulings run from each edge: normal (the default: along the normal of the face beside the edge), tangent (continuing that face), angle (`angle` from `direction`, else from the face's normal, turned away from the face) or direction (along `direction`). Matching ignores case."
);
keyword_param!(
/// `surface.ruled`'s `corners`.
RuledCornersParam,
"corners",
&["mitre", "miter", "round"],
"mitre or round",
"How the strip turns the chain's sharp corners: mitre (the default; miter is an alias) or round. Inside corners are always cut back. Matching ignores case."
);
keyword_param!(
/// `solid.fillet`'s `type`.
FilletTypeParam,
-1
View File
@@ -25,4 +25,3 @@ solid.thread
surface.extrude
surface.patch
surface.revolve
surface.ruled
+1
View File
@@ -1118,6 +1118,7 @@ fn hostile_params_never_panic() {
"favorites",
"filter",
"kind",
"detail",
"prefix",
"orientation",
"seam",
+102 -38
View File
@@ -998,7 +998,7 @@ fn m10_stock(z0: f64, z1: f64, rmax: impl Fn(f64) -> f64) -> f64 {
}
/// ISO 4753's chamfered end on both free ends of an M10 stud: a 45° cone down to the external
/// minor diameter d3 (ISO 724), cut after the groove; the volume as the stock's chamfered section
/// minor diameter d3 (ISO 724); the volume as the stock's chamfered section
/// with the thread's material fraction (as ladder 06/006). d3 lies below the modelled root, so
/// each end face is the whole disk of d3, with the cone a whole ring around it, not pinched
/// where the groove reaches the end.
@@ -1140,48 +1140,112 @@ fn a_runout_fades_the_groove_out_before_the_shoulder() {
assert!(e.err().map(|e| e.to_string()).unwrap_or_default().contains("`ease_in` is gone"));
}
/// A modelled M10 thread's flanks are several B-spline faces per turn (spans of one sweep); where
/// two of them meet with one tangent plane and one curvature the join is left out of the shaded
/// view (still pickable and in the wireframe), so the flank reads as one surface. The normal angle across each join is measured
/// independently (`seam_diagnostics`, projecting stations onto both surfaces).
#[test]
fn a_modelled_threads_flank_joins_are_not_drawn() {
fn a_saved_segmented_thread_flank_pick_does_not_select_a_different_face() {
use solidcore_record::brep::Surface;
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 5.0, "height": 12, "body_name": "Shaft"}));
run(&mut s, "solid.thread", json!({"face": [5.0, 0, 6.0], "designation": "M10x1.5", "modeled": true}));
let st = s.model.state();
let body = &st.body("Shaft").unwrap().body;
let mesh = body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.01)).unwrap();
let record = body.record();
let free_form = |f: u32| {
record
.faces
.get(f as usize)
.is_some_and(|f| f.nurbs.is_none() && matches!(f.surface, Surface::Spline { .. } | Surface::RationalSpline { .. }))
};
let tolerances = solidcraft_kernel::ContinuityTolerances { gap_mm: 1e-3, angle_deg: 0.5, curvature_per_mm: 1.0, ..Default::default() };
let report = solidcraft_kernel::seam_diagnostics(body, &[], 33, tolerances).unwrap();
let mut flank_joins = 0;
let mut worst: f64 = 0.0;
for seam in &report.seams {
let e = seam.edge;
let uses = &record.edges[e].uses;
if uses.len() != 2 || !uses.iter().all(|u| free_form(u.face)) {
continue;
// Captured from the segmented build before continuous_flanks existed, including the old
// groove-before-chamfer construction. Picks near both chamfered ends exercise its order.
let cases = [
("none", vec![("F2:face:12", Vec3::new(4.7828951862188696, -0.0057530612776590315, 1.6606274291037717))]),
(
"both",
vec![
("F2:face:41", Vec3::new(0.09242260275006192, 4.273234209721337, 0.23586874398281996)),
("F2:face:7", Vec3::new(0.9625365002593353, -4.6849682261915895, 6.262443362555148)),
("F2:face:51", Vec3::new(-0.0924226027501412, 4.273234209721336, 11.764131256017183)),
],
),
];
for (tip, picks) in cases {
let mut thread = json!({
"designation": "M10x1.5", "face": [5, 0, 6], "face_names": ["F1:side"],
"id": 2, "modeled": true, "name": "Thread1", "suppressed": false, "type": "thread"
});
if tip != "none" {
thread["tip"] = json!(tip);
}
let angle = seam.normal_angle_deg.value;
if angle < 0.5 {
flank_joins += 1;
worst = worst.max(angle);
assert!(mesh.smooth_edges[e] && !mesh.shaded_edge_visible(e), "join {e} at {angle} deg is drawn");
assert!(mesh.edge_visible(e) && mesh.edge_pickable(e), "join {e} stays in the wireframe and pickable");
} else {
assert!(mesh.shaded_edge_visible(e), "crease {e} at {angle} deg is hidden");
let mut saved: Value = serde_json::from_str(&solidcraft_doc::Document::default().to_json()).unwrap();
saved["features"] = json!([
{
"axis": [0, 0, 1], "base": [0, 0, 0], "body_names": ["Shaft"], "height": "12",
"id": 1, "name": "Cylinder1", "operation": "new_body", "param_names": ["d1", "d2"],
"radius": "5", "suppressed": false, "type": "cylinder"
}, thread
]);
saved["next_id"] = json!(3);
saved["params"] = json!([]);
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved.to_string()).unwrap());
let check_picks = |session: &Session| {
let rebuilt = session.model.state();
let shaft = rebuilt.body("Shaft").unwrap();
for (name, pick) in &picks {
let (face, point) = solidcraft_doc::naming::face_on_body(shaft, name, *pick).unwrap();
assert!(point.dist(*pick) < 0.01, "{tip}: {name} must stay on its original flank: {point:?}");
assert!(
matches!(shaft.body.record().faces[face].surface, Surface::Spline { .. } | Surface::RationalSpline { .. }),
"{tip}: {name} must not select a cylinder"
);
}
};
check_picks(&reopened);
run(
&mut reopened,
"timeline.redefine",
json!({
"feature": 2, "command": "solid.thread",
"params": {"face": [5, 0, 6], "designation": "M10x1.5", "modeled": true, "tip": tip}
}),
);
assert!(matches!(reopened.doc.feature(2).unwrap().kind, solidcraft_doc::FeatureKind::Thread { continuous_flanks: false, .. }));
check_picks(&reopened);
let saved_again = solidcraft_doc::Document::from_json(&reopened.doc.to_json()).unwrap();
assert!(matches!(saved_again.feature(2).unwrap().kind, solidcraft_doc::FeatureKind::Thread { continuous_flanks: false, .. }));
}
}
/// A modelled M10 thread keeps each flank as one B-spline face across all turns. Cylinder seams
/// must not split either flank into separate faces for selection, meshing or STEP export.
#[test]
fn a_modelled_thread_keeps_each_flank_as_one_face() {
use solidcore_record::brep::Surface;
// Include a partial final turn and a chamfered stock: neither may fragment the flanks.
for (height, tip) in [(12.0, "none"), (12.345, "none"), (12.345, "both")] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 5.0, "height": height, "body_name": "Shaft"}));
run(&mut s, "solid.thread", json!({"face": [5.0, 0, height / 2.0], "designation": "M10x1.5", "modeled": true, "tip": tip}));
let st = s.model.state();
let shaft = st.body("Shaft").unwrap();
let body = &shaft.body;
let mesh = body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.01)).unwrap();
let record = body.record();
let flanks: Vec<usize> = record
.faces
.iter()
.enumerate()
.filter(|(_, f)| f.nurbs.is_none() && matches!(f.surface, Surface::Spline { .. } | Surface::RationalSpline { .. }))
.map(|(i, _)| i)
.collect();
assert_eq!(flanks.len(), 2, "one selectable face per flank, independent of the turn count");
let names = solidcraft_doc::naming::face_names(shaft);
let picks: Vec<_> = flanks
.iter()
.map(|&flank| {
assert!(mesh.tri_face.iter().any(|f| *f as usize == flank), "flank {flank} must tessellate");
(names[flank].clone(), solidcraft_doc::naming::face_spot(shaft, flank).unwrap().point)
})
.collect();
// The named picks downstream face commands store must still find the same kind of face
// after the history is serialized and rebuilt, including the reordered chamfer cuts.
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let rebuilt = reopened.model.state();
let shaft = rebuilt.body("Shaft").unwrap();
for (name, point) in picks {
let (face, found) = solidcraft_doc::naming::face_on_body(shaft, &name, point).unwrap();
assert!(found.dist(point) < 1e-6, "the saved flank pick must remain on its named face: {name}");
assert!(matches!(shaft.body.record().faces[face].surface, Surface::Spline { .. } | Surface::RationalSpline { .. }));
}
}
// SMLib's flank pieces meet with normals about 1.5e-6 degrees apart (measured).
assert!(flank_joins >= 32 && worst < 1e-4, "{flank_joins} flank joins, the worst {worst} deg apart");
}
/// A pipe thread's profile for [`pipe_material`]: (flank half angle, thread height h, flat at crest
+1 -1
View File
@@ -13,7 +13,7 @@ use std::process::Command;
/// The workspace crates a design's model is computed from: `doc` and every workspace crate it
/// depends on (a test checks the list against their manifests).
const EVALUATION_CRATES: [&str; 11] = ["color", "geom", "profile", "smlib", "step", "sketch", "kernel", "render", "derive", "doc", "viewport"];
const EVALUATION_CRATES: [&str; 10] = ["color", "geom", "profile", "smlib", "sketch", "kernel", "render", "derive", "doc", "viewport"];
/// The other files the saved model depends on, relative to the workspace root.
const EVALUATION_FILES: [&str; 3] = ["crates/io/build.rs", "crates/io/src/design/native_cache.rs", ".cargo/config.toml"];
/// The committed trees whose every file computes models, hashed as one tree id each: the vendored
+1 -1
View File
@@ -15,7 +15,7 @@ solidcore-tess = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-smlib = { workspace = true }
solidcraft-profile = { workspace = true }
solidcraft-step = { workspace = true }
solidcore-step = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+1 -1
View File
@@ -721,7 +721,7 @@ impl Body {
/// The record as solidcore-ops' body and the order of its entities, made on first use and
/// kept for every later face; a conversion that fails is not kept. None for a mesh body.
fn ops_body(&self) -> Option<&(solidcore_ops::Body, solidcore_ops::Order)> {
pub(crate) fn ops_body(&self) -> Option<&(solidcore_ops::Body, solidcore_ops::Order)> {
if self.is_mesh() {
return None;
}
+4 -5
View File
@@ -25,7 +25,8 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec
/// [`sweep_helix`] with the profile moved off the axis as it turns: at turn t it first moves
/// `offset(t)` mm away from the axis (negative: towards it) in its half-plane, `offset` piecewise
/// linear through `law`'s (turn, offset) pairs, turns ascending within [0, `turns`]. A thread's
/// groove fading in or out over a runout: SMLib's radial screw sweep.
/// groove fading in or out over a runout: SMLib's radial screw sweep. `continuous_flanks`
/// joins the spline walls across cylinder seams; false retains the historical faces.
pub fn sweep_helix_radial(
plane: &Plane,
region: &Region2,
@@ -34,18 +35,16 @@ pub fn sweep_helix_radial(
pitch: f64,
turns: f64,
law: &[(f64, f64)],
continuous_flanks: bool,
) -> Result<Body> {
let _span = solidcraft_profile::span("kernel", "sweep_helix_radial");
if law.is_empty() {
return sweep_helix(plane, region, axis_origin, axis, pitch, turns);
}
let (k, profile) = helix_profile(plane, region, axis_origin, axis, pitch, turns)?;
if !law.windows(2).all(|w| matches!(w, [a, b] if b.0 > a.0))
|| law.iter().any(|(t, o)| !(t.is_finite() && o.is_finite() && *t >= 0.0 && *t <= turns))
{
return Err(KernelError::Invalid("helix: the radial law's turns must ascend within the sweep's turns".into()));
}
crate::smlib_sweeps::screw_radial(plane, &crate::smlib_sweeps::pieces([&profile]), axis_origin, k, pitch, turns, law)
crate::smlib_sweeps::screw_radial(plane, &crate::smlib_sweeps::pieces([&profile]), axis_origin, k, pitch, turns, law, continuous_flanks)
}
/// A helical sweep's axis and profile (the region's outer loop), checked: finite turns and
+3 -3
View File
@@ -3,15 +3,15 @@
//! with their faces, loops, edge and vertex lists, on analytic surfaces (planes, cylinders,
//! cones, spheres, tori), surfaces of revolution and rational B-splines, with body names (406
//! name properties) and colours (314). Geometry is the record's, exact, in the same STEP form
//! the STEP export writes ([`solidcraft_step::write::topology`]); IGES loops run
//! the STEP export writes ([`solidcore_step::write::topology`]); IGES loops run
//! counter-clockwise about their surface's normal.
use std::collections::HashMap;
use std::fmt::Write as _;
use solidcore_record::brep::{Brep, Loop, Shell};
use solidcraft_step::geom::{Curve, Placement, Surface};
use solidcraft_step::write::topology;
use solidcore_step::geom::{Curve, Placement, Surface};
use solidcore_step::write::topology;
use crate::{ExportBody, KernelError, Result, StepHeader};
+8
View File
@@ -26,6 +26,11 @@ pub enum BoolOp {
/// SMLib's own tags, or none where a body's order cannot be matched to SMLib's. A cut by a tool
/// apart from the body is the body itself (its faces and edges kept), and their intersection empty.
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Stopped by its watch (an agent's request cancelled while it computes, #1989): no more
// booleans. One SMLib already runs to its end.
if crate::watch::cancelled() {
return Err(crate::watch::cancelled_error("boolean"));
}
crate::profile::timed(crate::profile::Kind::Boolean, "boolean", || {
// A tool apart from the body (their exact boxes, grown by the tolerances, do not meet)
// leaves a cut the body itself and an intersection nothing, with no intersection work.
@@ -47,6 +52,9 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
/// what the operands' faces stood for (their regularization protection and the planar roles on
/// supports it kept).
pub fn union_all(bodies: &[&Body]) -> Result<Body> {
if crate::watch::cancelled() {
return Err(crate::watch::cancelled_error("union"));
}
crate::profile::timed(crate::profile::Kind::Boolean, "union_all", || {
if bodies.is_empty() {
return Err(KernelError::Invalid("a union of no bodies".into()));
+132 -76
View File
@@ -97,7 +97,6 @@ mod imp {
use super::*;
use crate::history::FaceProv;
use crate::provenance::{closest_on_tri, face_tris};
use crate::record;
fn failed(what: &str, e: impl crate::Cause) -> KernelError {
@@ -342,88 +341,144 @@ mod imp {
Unknown,
}
/// The role of each face of `body` swept from the profile in `start` (see the module notes):
/// `edges` lie on the walls' edges in the start plane, `inside` inside the walls; the face in
/// `end` (or, with `end` unset and `rest_is_end`, a planar face nothing else claims) is the
/// end cap.
/// The role of each face of `body` swept from the profile in `start` (see the module notes),
/// from the body's exact geometry: a face on the start plane is the start cap where a point
/// of `edges` lies on it, one on `end` the end cap (with `end` unset and `rest_is_end`, a
/// planar face nothing else claims); a wall comes from each piece whose point in `edges` lies
/// on its edge in the start plane, or whose point in `inside` lies on it. Each point names
/// the nearest edges or faces: one where SMLib split a curve or a wall lies on both.
fn roles(body: &Body, start: &Plane, end: Option<&Plane>, edges: &[Seed], inside: &[Seed], rest_is_end: bool) -> Result<Vec<Role>> {
use solidcore_record::brep::{Curve as RecordCurve, Surface};
use solidcraft_geom::brep::eval::CurveEval;
let r = body.record();
let size = body.size().max(1e-9);
let mesh_tol = (size * 2e-4).max(1e-6);
let m = body.mesh(&crate::MeshTolerance::chord(mesh_tol))?;
let faces = face_tris(&m);
let n = body.face_count();
// A cap's points lie on its plane to the evaluation's precision. Sized to the body, the
// flatness tolerance can exceed the gap between parallel start and end planes (a box
// 0.1 µm thick in a 14 mm body), which would put each cap in both: it stays under a
// quarter of that gap. Planes that coincide (the ends of a half turn) are told apart by
// the profile's edges instead.
// A cap lies on its plane to the evaluation's precision. Sized to the body, that can exceed
// the gap between parallel start and end planes (a box 0.1 µm thick in a 14 mm body), which
// would put each cap on both: it stays under a quarter of that gap.
let gap = end
.filter(|e| e.normal().cross(start.normal()).len() < 1e-9)
.map(|e| start.height(e.origin).abs())
.filter(|g| *g > size * 1e-9)
.filter(|g| end.is_some_and(|e| e.normal().cross(start.normal()).len() < 1e-9) && *g > size * 1e-9)
.unwrap_or(f64::INFINITY);
let flat_tol = (size * 1e-5 + 1e-9).min(0.25 * gap);
let near = 3.0 * mesh_tol + flat_tol;
let flat = |f: usize, pl: &Plane| {
faces.get(f).is_some_and(|ft| !ft.tris.is_empty() && ft.tris.iter().all(|(t, _)| t.iter().all(|q| pl.height(*q).abs() <= flat_tol)))
let tol = (size * 1e-5 + 1e-9).min(0.25 * gap);
let vertex = |v: u32| r.vertices.get(v as usize).map(|v| crate::body::v3(v.point));
// A planar face's point and unit normal.
let flat = |f: &solidcore_record::brep::Face| {
let (o, u, v) = match &f.surface {
Surface::Plane { origin, u_axis, v_axis } => (crate::body::v3(*origin), crate::body::v3(*u_axis), crate::body::v3(*v_axis)),
Surface::PlaneThrough { origin, u_point, v_point } => {
let o = crate::body::v3(*origin);
(o, crate::body::v3(*u_point) - o, crate::body::v3(*v_point) - o)
}
_ => return None,
};
u.cross(v).normalized().map(|n| (o, n))
};
let planar =
|f: usize| faces.get(f).is_some_and(|ft| ft.tris.first().is_some_and(|(_, n0)| ft.tris.iter().all(|(_, nn)| nn.dot(*n0) > 1.0 - 1e-6)));
// A face in the start plane is the start cap where the profile bounds it (a screw's far
// end after whole turns lies in the same plane, elsewhere).
let touches = |f: usize| {
edges.is_empty()
|| faces.get(f).is_some_and(|ft| edges.iter().any(|s| ft.tris.iter().any(|(t, _)| closest_on_tri(s.at, *t).dist(s.at) <= near)))
let on = |f: &solidcore_record::brep::Face, pl: &Plane| {
flat(f).is_some_and(|(o, n)| n.dot(pl.normal()).abs() >= 1.0 - 1e-9 && pl.height(o).abs() <= tol)
};
let start_cap: Vec<bool> = (0..n).map(|f| flat(f, start) && touches(f)).collect();
let end_cap: Vec<bool> = (0..n).map(|f| end.is_some_and(|e| flat(f, e))).collect();
let cap = |f: usize| start_cap.get(f).copied().unwrap_or(false) || end_cap.get(f).copied().unwrap_or(false);
let mut swept: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
// Each seed lies on its own piece's edge (within the mesh's sag on a curve); `near` is
// sized to the body, so on a side shorter than it (a 0.3 mm wall of a 1 m label) the
// neighbours' edges are near too. The seed marks only the edges nearest it: the one it
// lies on, or both pieces of an edge split at its middle.
let start_edges: Vec<(&Vec<Vec3>, &Vec<u32>)> = m
.edges
.iter()
.zip(&m.edge_faces)
.filter(|(poly, _)| poly.len() >= 2 && poly.iter().all(|q| start.height(*q).abs() <= flat_tol))
.collect();
// The edges in the start plane: their ends and middle on it.
let mut base = Vec::new();
for (i, e) in r.edges.iter().enumerate() {
let (Some(a), Some(b)) = (vertex(e.vertices[0]), vertex(e.vertices[1])) else { continue };
// An edge SMLib made within its own tolerance of the curve it stands for (a screw's
// start) is taken to that tolerance.
let reach = tol.max(e.tolerance);
if start.height(a).abs() > reach || start.height(b).abs() > reach {
continue;
}
let curve = CurveEval::new(e.parameters()).map_err(|err| KernelError::Failed(format!("extrude history: edge {i}: {err}")))?;
let [t0, t1] = e.interval;
let mid = curve.point(0.5 * (t0 + t1)).ok_or_else(|| KernelError::Failed(format!("extrude history: edge {i} has no middle")))?;
if start.height(mid).abs() > reach {
continue;
}
// The curve's control points bound it.
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
match e.parameters() {
RecordCurve::Line { .. } => [a, b].into_iter().for_each(|q| bounds.add(q)),
RecordCurve::Spline { points, .. } => points.iter().for_each(|q| bounds.add(crate::body::v3(*q))),
RecordCurve::RationalSpline { homogeneous, .. } if homogeneous.iter().all(|h| h[3] > 0.0) => {
homogeneous.iter().for_each(|h| bounds.add(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])))
}
_ => {
bounds = solidcraft_geom::Aabb3 { min: Vec3::new(-f64::MAX, -f64::MAX, -f64::MAX), max: Vec3::new(f64::MAX, f64::MAX, f64::MAX) }
}
}
// A line by its ends; a curve searched from eight samples a knot span.
let samples = match e.parameters() {
RecordCurve::Line { .. } => None,
RecordCurve::Spline { knots, .. } | RecordCurve::RationalSpline { knots, .. } => {
Some(8 * knots.windows(2).filter(|w| w[1] > w[0]).count().max(8))
}
_ => Some(64),
};
base.push((i, curve, e.interval, [a, b], samples, reach, bounds.inflated(reach)));
}
// Each seed's nearest edges, and the faces on them.
let mut matched: Vec<(usize, usize)> = Vec::new();
for s in edges {
let dist = |poly: &[Vec3]| poly.windows(2).map(|w| s.at.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let ds: Vec<f64> = start_edges.iter().map(|(poly, _)| dist(poly)).collect();
let nearest = ds.iter().copied().fold(f64::INFINITY, f64::min);
for ((_, fs), d) in start_edges.iter().zip(&ds) {
if *d <= near && *d <= nearest + flat_tol {
for f in fs.iter().map(|f| *f as usize).filter(|f| !cap(*f)) {
if let Some(set) = swept.get_mut(f) {
set.insert(s.piece);
}
}
let ds: Vec<(usize, f64, f64)> = base
.iter()
.filter(|(.., bounds)| bounds.contains(s.at))
.map(|(i, curve, interval, [a, b], samples, reach, _)| {
let Some(samples) = samples else { return (*i, s.at.dist_to_segment(*a, *b), *reach) };
let along = crate::surfaces::nearest_parameter_in(curve, *interval, s.at, *samples).map_or(f64::INFINITY, |(_, d)| d);
(*i, along.min(s.at.dist(*a)).min(s.at.dist(*b)), *reach)
})
.collect();
let nearest = ds.iter().map(|(_, d, _)| *d).fold(f64::INFINITY, f64::min);
matched.extend(ds.iter().filter(|(_, d, reach)| *d <= *reach && *d <= nearest + tol).map(|(i, ..)| (*i, s.piece)));
}
let faces_on = |e: usize| r.edges.get(e).into_iter().flat_map(|e| e.uses.iter().map(|u| u.face as usize));
let touched: BTreeSet<usize> = matched.iter().flat_map(|(e, _)| faces_on(*e)).collect();
let n = r.faces.len();
// How far a point may lie off a face SMLib made to its own tolerance.
let reach = r.edges.iter().map(|e| e.tolerance).fold(tol, f64::max);
let ops = || body.ops_body().ok_or_else(|| KernelError::Failed("sweep history: the body is no SMLib body".into()));
let points = |seeds: &[Seed]| seeds.iter().map(|s| p(s.at)).collect::<Vec<_>>();
// A face in the start plane is the start cap where a profile point lies on it: on its edges
// (an extrusion's), or inside it (a screw's start section, SMLib's approximation of the profile).
let mut start_cap = Vec::with_capacity(r.faces.len());
for (f, face) in r.faces.iter().enumerate() {
let on_it = on(face, start)
&& (edges.is_empty() || touched.contains(&f) || {
let (ops, order) = ops()?;
let ds =
ops.face_distances(order, f, &points(edges)).map_err(|e| KernelError::Failed(format!("sweep history: face {f}: {e}")))?;
ds.iter().any(|d| d.is_some_and(|d| d <= reach))
});
start_cap.push(on_it);
}
let end_cap: Vec<bool> = r.faces.iter().map(|face| end.is_some_and(|e| on(face, e))).collect();
let mut swept: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
for (e, piece) in &matched {
for f in faces_on(*e) {
if !start_cap.get(f).copied().unwrap_or(false)
&& !end_cap.get(f).copied().unwrap_or(false)
&& let Some(set) = swept.get_mut(f)
{
set.insert(*piece);
}
}
}
// Inside the walls: the face nearest each point (bounded work).
let tris: usize = faces.iter().map(|f| f.tris.len()).sum();
if tris.saturating_mul(inside.len()) <= 50_000_000 {
for s in inside {
let mut best = (f64::INFINITY, usize::MAX);
for (fi, ft) in faces.iter().enumerate() {
if !ft.bounds.inflated(best.0.min(size)).contains(s.at) {
continue;
// Inside the walls: the faces nearest each point, as trimmed (a point on the edge between
// two faces of one wall lies on both).
if !inside.is_empty() {
let (ops, order) = ops()?;
let points = points(inside);
let ds = (0..n)
.map(|f| ops.face_distances(order, f, &points).map_err(|e| KernelError::Failed(format!("sweep history: face {f}: {e}"))))
.collect::<Result<Vec<_>>>()?;
for (j, s) in inside.iter().enumerate() {
let d = |f: usize| ds.get(f).and_then(|d| d.get(j).copied().flatten()).unwrap_or(f64::INFINITY);
let nearest = (0..n).map(d).fold(f64::INFINITY, f64::min);
for f in (0..n).filter(|f| d(*f) <= reach && d(*f) <= nearest + tol) {
if !start_cap.get(f).copied().unwrap_or(false)
&& !end_cap.get(f).copied().unwrap_or(false)
&& let Some(set) = swept.get_mut(f)
{
set.insert(s.piece);
}
for (t, _) in &ft.tris {
let d = closest_on_tri(s.at, *t).dist(s.at);
if d < best.0 {
best = (d, fi);
}
}
}
if best.0 <= 5.0 * near
&& !cap(best.1)
&& let Some(set) = swept.get_mut(best.1)
{
set.insert(s.piece);
}
}
}
@@ -437,7 +492,7 @@ mod imp {
Role::End
} else if !set.is_empty() {
Role::Wall(set)
} else if end.is_none() && rest_is_end && planar(f) {
} else if end.is_none() && rest_is_end && r.faces.get(f).is_some_and(|face| flat(face).is_some()) {
Role::End
} else {
log::debug!(target: "smlib", "sweep history: face {f} comes from no profile segment");
@@ -519,9 +574,7 @@ mod imp {
let rule = draft.map_or(DraftCorner::default(), |d| corner(d.corner));
let brep = Brep::extrude(&c, p(dir), distance, lean, rule, true).map_err(|e| failed("extrude", e))?;
let body = body_of(brep, "extrude")?;
// Half-way along a straight wall lies over its segment; a drafted one has moved.
let inside = if draft.is_none() { seeds(&base, pieces, |q| q + dir * (distance * 0.5)) } else { Vec::new() };
let roles = roles(&body, &base, Some(&top), &edges, &inside, false)?;
let roles = roles(&body, &base, Some(&top), &edges, &[], false)?;
let sources = (draft.is_none() && sign > 0.0).then(|| extrude_sources(&roles, pieces, region, plane, &base, &top));
let body = body.with_face_sources(sources.map(std::sync::Arc::new));
with_history(body, &roles, pieces)
@@ -1837,11 +1890,14 @@ mod imp {
pitch: f64,
turns: f64,
law: &[(f64, f64)],
continuous_flanks: bool,
) -> Result<Body> {
let c = curves(plane, pieces)?;
let tolerance = (pitch.abs() * 1e-4).clamp(1e-6, 1e-2);
let options =
solidcraft_smlib::sweeps::ScrewSweepOptions { law: (!law.is_empty()).then_some(law), join_spline_walls: continuous_flanks, tolerance };
let brep =
Brep::screw_sweep_radial(&c, p(origin), p(axis), pitch, turns, law, tolerance).map_err(|e| failed("screw sweep with a radial law", e))?;
Brep::screw_sweep_with_options(&c, p(origin), p(axis), pitch, turns, options).map_err(|e| failed("screw sweep with a radial law", e))?;
let body = body_of(brep, "screw sweep")?;
let roles = roles(&body, plane, None, &seeds(plane, pieces, |q| q), &[], true)?;
with_history(body, &roles, pieces)
+47
View File
@@ -76,6 +76,33 @@ fn an_extrusion_names_its_caps_and_walls_and_keeps_their_sources() {
assert!(origins(&b).iter().filter(|o| o.contains("Profile")).count() >= 1);
}
#[test]
fn an_extrusion_of_one_curve_drawn_in_many_pieces_names_every_piece_on_its_wall() {
// An ellipse in 256 cubic pieces, tangent through each joint, extruded as the one B-spline
// they are: its one wall comes from every piece, each found on the wall's edge in the sketch
// plane by its own middle (a search of that edge in too few places misses the last pieces).
let (n, a, b) = (256usize, 20.0, 7.0);
let h = TAU / n as f64;
let at = |i: usize| {
let t = (i % n) as f64 * h;
(Vec2::new(a * t.cos(), b * t.sin()), Vec2::new(-a * t.sin(), b * t.cos()) * (h / 3.0))
};
let segs = (0..n)
.map(|i| {
let ((p0, d0), (p3, d3)) = (at(i), at(i + 1));
Seg2::Cubic { p0, p1: p0 + d0, p2: p3 - d3, p3 }
})
.collect();
let ellipse = region(Loop2 { segs });
let mut pieces = pieces_of(&ellipse);
pieces.iter_mut().skip(1).for_each(|x| x.joins_previous = true);
let body = smlib_sweeps::extrude(&Plane::XY, &pieces, Some(&ellipse), 0.0, 3.0, 1.0, None).unwrap();
let every = format!("{:?}", (0..n).map(|segment| FaceOrigin::Profile { segment }).collect::<Vec<_>>());
let mut want = vec![cap(false), cap(true), every];
want.sort();
assert_eq!(origins(&body), want);
}
#[test]
fn a_revolution_names_its_walls_by_segment_and_skips_the_axis() {
// A half disc on the y axis of the xy plane: a sphere, every wall from the arc.
@@ -253,6 +280,26 @@ fn a_drafted_extrusion_stands_on_a_straight_foot() {
}
}
#[test]
fn a_drafted_extrusion_with_a_hole_names_each_side_on_its_foot_and_its_slope() {
// A 20 x 20 square round a 6 x 6 hole, 2 straight, then sloping 3 degrees (taken from the
// base) up to 10: the outer side grows to 20 + 2 x 10 tan 3, the hole's narrows to
// 6 - 2 x 10 tan 3, and each of the eight sides gives a foot and a sloping wall.
let outer = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 20.0), Vec2::new(0.0, 20.0)]);
let hole = Loop2::polygon(&[Vec2::new(7.0, 7.0), Vec2::new(7.0, 13.0), Vec2::new(13.0, 13.0), Vec2::new(13.0, 7.0)]);
let r = Region2 { outer, holes: vec![hole] };
let taper = 3f64.to_radians();
let b = crate::extrude_tapered_between(&Plane::XY, &r, 0.0, 10.0, 2.0, 1.0, taper, crate::DraftCorner::default()).unwrap();
let frustum = |s1: f64, s2: f64| 8.0 / 3.0 * (s1 * s1 + s2 * s2 + s1 * s2);
let grow = 2.0 * 10.0 * taper.tan();
close(volume(&b), (400.0 - 36.0) * 2.0 + frustum(20.0, 20.0 + grow) - frustum(6.0, 6.0 - grow), 1e-9, "drafted ring");
let o = origins(&b);
assert!(o.contains(&cap(false)) && o.contains(&cap(true)), "{o:?}");
for k in 0..8 {
assert_eq!(o.iter().filter(|x| **x == profile(k)).count(), 2, "segment {k}: {o:?}");
}
}
#[test]
fn a_twisted_sweep_round_a_closed_path_joins_its_ends() {
// An 8 x 8 square round a circle of radius 30, a quarter turn of twist (onto itself): Pappus.
+41 -22
View File
@@ -1,7 +1,7 @@
//! STEP import (ISO 10303-21 files, AP203/AP214/AP242 geometry).
//!
//! Our own reader, written from the public standards: [`p21`] parses the exchange structure,
//! [`solidcraft_step::topology`] reads each solid into its schema 4 record, which SMLib takes
//! [`solidcore_step::topology`] reads each solid into its schema 4 record, which SMLib takes
//! as the body ([`parallel`]), and this module walks the product structure (products, shape
//! representations, assemblies with their placements, units, colours). STEP files are
//! untrusted: every count and nesting depth is capped, and what cannot be read becomes a
@@ -14,12 +14,21 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use solidcraft_step::read::R;
use solidcore_step::read::R;
use crate::body::Body;
use crate::{KernelError, Result};
use p21::{Entity, Exchange, Param};
/// The representation the file's unlisted topology is read as, under an id the reader keeps for
/// it (it is used only when the file's representations hold nothing to read).
const UNLISTED: u64 = u64::MAX;
/// Whether an item of representation `rep` is read as a body: a solid or surface model, and in
/// the file's unlisted topology ([`UNLISTED`]) a shell or face on its own too.
fn body_item(rep: u64, name: &str) -> bool {
solidcore_step::topology::is_solid_item(name) || (rep == UNLISTED && solidcore_step::topology::is_body_item(name))
}
/// Deepest assembly nesting followed.
const MAX_ASSEMBLY_DEPTH: usize = 32;
/// Most body instances in one import.
@@ -175,13 +184,13 @@ struct Batch {
/// The units of a representation context (GLOBAL_UNIT_ASSIGNED_CONTEXT) and its distance
/// uncertainty (mm) as the file states it.
type Units = solidcraft_step::units::Context;
type Units = solidcore_step::units::Context;
/// Units of a representation context, read by [`solidcraft_step::units::context`] (millimetres
/// Units of a representation context, read by [`solidcore_step::units::context`] (millimetres
/// and radians when the file names none).
fn context_units(ex: &Exchange, ctx: Option<u64>, cache: &mut HashMap<u64, Units>) -> Units {
let Some(id) = ctx else { return Units::default() };
*cache.entry(id).or_insert_with(|| solidcraft_step::units::context(ex, id))
*cache.entry(id).or_insert_with(|| solidcore_step::units::context(ex, id))
}
/// Items and context of a representation entity.
@@ -251,7 +260,7 @@ fn translation(t: [f64; 3]) -> M4 {
}
/// The matrix of an AXIS2_PLACEMENT: its frame's axes and origin.
fn frame_matrix(p: &solidcraft_step::geom::Placement) -> M4 {
fn frame_matrix(p: &solidcore_step::geom::Placement) -> M4 {
let (x, y, z, o) = (p.ref_direction, p.y(), p.axis, p.origin);
[[x[0], x[1], x[2], 0.0], [y[0], y[1], y[2], 0.0], [z[0], z[1], z[2], 0.0], [o[0], o[1], o[2], 1.0]]
}
@@ -619,7 +628,7 @@ impl Reader {
/// omitted axis is the global one, an omitted scale 1, an omitted second or third scale the
/// first).
fn placement_matrix(&mut self, item: u64, u: Units) -> R<M4> {
let reader = solidcraft_step::read::Reader::new(&self.ex, u.units);
let reader = solidcore_step::read::Reader::new(&self.ex, u.units);
let e = self.ex.get(item).ok_or_else(|| format!("missing entity #{item}"))?;
if matches!(e.name(), "CARTESIAN_TRANSFORMATION_OPERATOR_3D" | "CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM") {
let p = e.params();
@@ -707,18 +716,12 @@ impl Reader {
}
let Some(e) = self.ex.get(item) else { continue };
match e.name() {
"MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL" => {
name if body_item(rep, name) => {
// Only batch consecutive solids: mapped items must retain traversal and
// first-use unit/cache order. Bound temporary geometry independently of file size.
let mut batch = vec![item];
let capacity = MAX_BODIES.saturating_sub(self.bodies_so_far()).min(64);
while batch.len() < capacity
&& items.peek().is_some_and(|id| {
self.ex.get(*id).is_some_and(|e| {
matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL")
})
})
{
while batch.len() < capacity && items.peek().is_some_and(|id| self.ex.get(*id).is_some_and(|e| body_item(rep, e.name()))) {
if let Some(next) = items.next() {
batch.push(next);
}
@@ -737,7 +740,7 @@ impl Reader {
// The node lists the planned solids (a surface model's closed shells
// beside its sheet are solids of their own, in the model's colour); each
// becomes a body index once converted.
for piece in solidcraft_step::topology::items(&self.ex, item) {
for piece in solidcore_step::topology::items(&self.ex, item) {
if piece != item {
if let Some(c) = self.colors.get(&item).copied() {
self.colors.entry(piece).or_insert(c);
@@ -1098,12 +1101,12 @@ impl Reader {
/// Faces whose boundary runs the wrong way round their normal, as a strict reader sees them:
/// each face's normal is its surface's own parametric normal (∂S/∂u × ∂S/∂v, ISO 10303-42),
/// reversed when `same_sense` is false, and the outer loop must run counter-clockwise seen from
/// that side. These are the faces the STEP reader turns over ([`solidcraft_step::topology`]),
/// that side. These are the faces the STEP reader turns over ([`solidcore_step::topology`]),
/// numbered as the solid's body numbers them. Returns one message per bad face; an error when
/// the file does not parse or has no solid the reader can read.
pub fn step_orientation_errors(text: &str) -> std::result::Result<Vec<String>, String> {
let ex = p21::parse(text)?;
let read = solidcraft_step::topology::read_solids_with(&ex, &solidcraft_smlib::pcurves::SmlibTrims);
let read = solidcore_step::topology::read_solids_with(&ex, &solidcraft_smlib::pcurves::SmlibTrims);
if read.solids.is_empty() {
return Err(read.failures.first().map_or_else(|| "the file has no solids".into(), |f| format!("solid #{}: {}", f.item, f.message)));
}
@@ -1302,10 +1305,7 @@ impl StepReading {
.iter()
.flat_map(|(r, (items, _))| items.iter().map(move |i| (*r, *i)))
.filter(|(_, i)| !rd.used_items.contains(i))
.filter(|(_, i)| {
ex.get(*i)
.is_some_and(|e| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL"))
})
.filter(|(_, i)| ex.get(*i).is_some_and(|e| solidcore_step::topology::is_solid_item(e.name())))
.map(|(r, _)| r)
.collect();
loose.sort();
@@ -1319,6 +1319,25 @@ impl StepReading {
}
tree.push(StepNode { name: "Import".into(), transform: identity, bodies, children: Vec::new() });
}
// Topology outside every representation (bare geometry files, or ones whose topology no
// representation lists): its unlisted solids, shells and faces, read in the file's one
// representation context where it has exactly one.
if rd.plan.is_empty() {
let roots = solidcore_step::topology::roots(&ex);
if !roots.is_empty() {
let mut contexts = ex.entities.iter().filter(|(_, e)| e.has("GEOMETRIC_REPRESENTATION_CONTEXT")).map(|(id, _)| *id);
let context = match (contexts.next(), contexts.next()) {
(Some(c), None) => Some(c),
_ => None,
};
rd.reps.insert(UNLISTED, (roots, context));
let mut bodies = Vec::new();
rd.node_items = rd.used_items.clone();
rd.visits = 0;
rd.rep_bodies(UNLISTED, &identity, &[], "Import", None, &mut bodies, 0);
tree.push(StepNode { name: "Import".into(), transform: identity, bodies, children: Vec::new() });
}
}
let single = workers <= 1;
Ok(StepReading { rd, tree, key, small: text.len() <= KEEP_SMALL_BYTES, single })
}
+5 -5
View File
@@ -1,10 +1,10 @@
//! The Part 21 parser lives in `solidcraft-step`; this reads with the kernel's progress watch.
//! The Part 21 parser is `solidcore-record`'s (re-exported by `solidcore-step`); this reads with the kernel's progress watch.
pub use solidcraft_step::p21::{Entity, Exchange, Param};
pub use solidcore_step::p21::{Entity, Exchange, Param};
struct Watched(std::sync::Arc<dyn crate::watch::Watch>);
impl solidcraft_step::p21::Progress for Watched {
impl solidcore_step::p21::Progress for Watched {
fn cancelled(&self) -> bool {
self.0.cancelled()
}
@@ -13,8 +13,8 @@ impl solidcraft_step::p21::Progress for Watched {
}
}
/// [`solidcraft_step::p21::parse`] under the current [`crate::watch`].
/// [`solidcore_step::p21::parse`] under the current [`crate::watch`].
pub fn parse(text: &str) -> Result<Exchange, String> {
let watch = crate::watch::current().map(Watched);
solidcraft_step::p21::parse_with(text, watch.as_ref().map(|w| w as &dyn solidcraft_step::p21::Progress))
solidcore_step::p21::parse_with(text, watch.as_ref().map(|w| w as &dyn solidcore_step::p21::Progress)).map_err(String::from)
}
+49 -12
View File
@@ -99,7 +99,7 @@ fn placed(body: &Body, m: &M4) -> crate::Result<Body> {
/// refuses fails with the reason ([`surface_part`] then imports what it can of it).
pub(super) fn smlib_part(ex: &Exchange, item: u64, units: Units) -> std::result::Result<(Part, Vec<Note>), Failure> {
let solid = solidcraft_profile::scope("import", "step.read_solid", || {
solidcraft_step::topology::read_solid_with(ex, item, units, &solidcraft_smlib::pcurves::SmlibTrims)
solidcore_step::topology::read_solid_with(ex, item, units, &solidcraft_smlib::pcurves::SmlibTrims)
})
.map_err(|msg| Failure { read: true, msg })?;
let part = solid_part(&solid)?;
@@ -112,7 +112,7 @@ pub(super) fn smlib_part(ex: &Exchange, item: u64, units: Units) -> std::result:
/// normal, so a sheet whose file uses some faces the other way is given to SMLib with those faces
/// turned over (exactly, on the record) and they are turned back on SMLib's body: the body faces
/// as the file does, and each of its faces is known to be the record's face it was.
fn solid_part(solid: &solidcraft_step::topology::Solid) -> std::result::Result<Part, Failure> {
fn solid_part(solid: &solidcore_step::topology::Solid) -> std::result::Result<Part, Failure> {
let failed = |e: KernelError| Failure { read: false, msg: e.to_string().trim_start_matches("the operation failed: ").to_string() };
let r = &solid.brep;
let mut reversed = vec![false; r.faces.len()];
@@ -138,13 +138,15 @@ fn solid_part(solid: &solidcraft_step::topology::Solid) -> std::result::Result<P
// `natural`'s face j is the record's `to_record[j]`; SMLib's body's face k is `natural`'s
// `of_natural[k]`; the body turned back's face i is SMLib's `back[i]`.
let (natural, to_record) = turn(&as_read, &|f| reversed.get(f).copied().unwrap_or(false)).map_err(failed)?;
let (smlib, of_natural) = smlib_body(natural.record())?;
// SMLib names a face it refuses by its index in the record it was given, `natural`'s.
let natural_origin: Vec<u64> = to_record.iter().map(|f| solid.face_origin.get(*f).copied()).collect::<Option<_>>().unwrap_or_default();
let (smlib, of_natural) = smlib_body(natural.record()).map_err(|f| Failure { msg: name_record_face(&f.msg, &natural_origin), ..f })?;
let record_of = |k: usize| of_natural.get(k).and_then(|j| to_record.get(*j)).copied();
let (back, from) = turn(&smlib, &|k| record_of(k).and_then(|f| reversed.get(f)).copied().unwrap_or(false)).map_err(failed)?;
let of_record = from.iter().map(|k| record_of(*k)).collect::<Option<Vec<usize>>>().ok_or_else(renumbered)?;
(back.without_history(), of_record)
} else {
smlib_body(r)?
smlib_body(r).map_err(|f| Failure { msg: name_record_face(&f.msg, &solid.face_origin), ..f })?
};
let face_origin = of_record.iter().map(|f| solid.face_origin.get(*f).copied()).collect::<Option<Vec<u64>>>().ok_or_else(renumbered)?;
Ok(Part { body, closed: !solid.sheet, file_faces: solid.face_origin.len(), face_origin, refused: None })
@@ -243,7 +245,7 @@ const MAX_LEFT_OUT_ROUNDS: usize = 8;
/// A solid the reader or SMLib refuses, imported as an open surface body of its exact faces, the
/// faces that make it refused left out and named in the notes; `refused` says why the solid was.
/// Each face is read on its own first ([`solidcraft_step::topology::read_faces_with`]); then:
/// Each face is read on its own first ([`solidcore_step::topology::read_faces_with`]); then:
/// 1. the solid is read again without the faces that cannot be read alone and the face the
/// refusal names, as one sheet whose faces share their edges as the file has them, and made
/// by SMLib; a refusal of that naming another face of the solid leaves it out too (up to
@@ -256,7 +258,7 @@ const MAX_LEFT_OUT_ROUNDS: usize = 8;
fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std::result::Result<(Part, Vec<Note>), Failure> {
let _span = solidcraft_profile::span("import", "step.refused_surfaces");
let trims = &solidcraft_smlib::pcurves::SmlibTrims;
let Ok(faces) = solidcraft_step::topology::read_faces_with(ex, item, units, trims) else { return Err(refused) };
let Ok(faces) = solidcore_step::topology::read_faces_with(ex, item, units, trims) else { return Err(refused) };
let of_solid: std::collections::HashSet<u64> = faces.iter().map(|f| f.face).collect();
// The faces left out, each with the refusal that names it, in the order they were found.
let mut left_out: Vec<(u64, String)> = faces.iter().filter_map(|f| f.record.as_ref().err().map(|m| (f.face, m.clone()))).collect();
@@ -264,7 +266,7 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
let mut rounds = 0;
loop {
// The face the last refusal names, if it is one of the solid's still in, is left out too.
let named = solidcraft_step::topology::named_face(&why).filter(|f| of_solid.contains(f) && !left_out.iter().any(|(g, _)| g == f));
let named = solidcore_step::topology::named_face(&why).filter(|f| of_solid.contains(f) && !left_out.iter().any(|(g, _)| g == f));
match named {
Some(f) => left_out.push((f, why.clone())),
None if rounds > 0 => break,
@@ -276,11 +278,11 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
rounds += 1;
let skip: std::collections::HashSet<u64> = left_out.iter().map(|(f, _)| *f).collect();
let read =
solidcraft_profile::scope("import", "step.read_without", || solidcraft_step::topology::read_solid_without(ex, item, units, trims, &skip));
match read
.map_err(|msg| Failure { read: true, msg })
.and_then(|solid| Ok((solid_part(&solid)?, solid.notes.into_iter().map(Note::Repaired).collect::<Vec<_>>())))
{
solidcraft_profile::scope("import", "step.read_without", || solidcore_step::topology::read_solid_without(ex, item, units, trims, &skip));
match read.map_err(|msg| Failure { read: true, msg }).and_then(|solid| {
let part = solid_part(&solid)?;
Ok((part, solid.notes.into_iter().map(Note::Repaired).collect::<Vec<_>>()))
}) {
Ok((mut part, mut notes)) => {
part.file_faces = faces.len();
part.refused = Some(refused.msg);
@@ -297,11 +299,29 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
let mut notes = Vec::new();
let mut pieces = Vec::with_capacity(faces.len());
for f in &faces {
// A face the solid's shells use more than once (an outer shell and its voids written
// with one face) is one piece, once.
if pieces.iter().any(|(g, _)| *g == f.face) {
notes.push(Note::LeftOut(format!("left out: face #{}: a further use of it by another shell of the solid", f.face)));
continue;
}
match f.record.clone().map_err(KernelError::Failed).and_then(Body::from_record) {
Ok(b) => pieces.push((f.face, b)),
Err(e) => notes.push(Note::LeftOut(format!("left out: {}", e.to_string().trim_start_matches("the operation failed: ")))),
}
}
// A solid of one face that cannot be made has nothing else to give: the refusal says it is the
// only face, which face, and why (an edge's distance from the surface, a vertex's from its
// curve), never only that the import failed.
let distinct: std::collections::HashSet<u64> = faces.iter().map(|f| f.face).collect();
if pieces.is_empty()
&& distinct.len() == 1
&& let Some(f) = faces.first()
{
let why = left_out.iter().find(|(g, _)| *g == f.face).map_or(refused.msg.as_str(), |(_, m)| m.as_str());
let why = why.split_once("first: ").map_or(why, |(_, rest)| rest);
return Err(Failure { msg: format!("the solid's only face, #{}, cannot be made: {why}", f.face), ..refused });
}
// Where SMLib cannot stitch them either, the solid is refused with the reason it was.
let part = stitched_part(&pieces, faces.len(), &refused.msg).map_err(|e| {
log::debug!(target: "step", "item #{item}: its faces not stitched ({e})");
@@ -310,12 +330,29 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
Ok((part, notes))
}
/// A refusal of SMLib's that names a face of the record it was given by its index there
/// ("flat arrays: face 12: …"), with that face named as the file's entity too ("face #1167"), so
/// the face can be left out and the import say which it was.
pub(super) fn name_record_face(msg: &str, face_origin: &[u64]) -> String {
let Some(at) = msg.find("flat arrays: face ") else { return msg.to_string() };
let Some(rest) = msg.get(at + "flat arrays: face ".len()..) else { return msg.to_string() };
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
match digits.parse::<usize>().ok().and_then(|i| face_origin.get(i)) {
Some(step) => format!("face #{step}: {msg}"),
None => msg.to_string(),
}
}
/// The one-face surface bodies `pieces` (each with its file face) stitched by SMLib into one
/// surface body, where their edges coincide within ten times the pieces' tolerance.
fn stitched_part(pieces: &[(u64, Body)], file_faces: usize, refused: &str) -> crate::Result<Part> {
if pieces.is_empty() {
return Err(KernelError::Failed("no face read".into()));
}
// One face read alone is the sheet: nothing to stitch it to.
if let [(face, body)] = pieces {
return Ok(Part { body: body.clone(), closed: false, file_faces, face_origin: vec![*face], refused: Some(refused.to_string()) });
}
let tol = 10.0 * pieces.iter().map(|(_, b)| b.record().tolerance).fold(0.0, f64::max);
let bodies: Vec<&Body> = pieces.iter().map(|(_, b)| b).collect();
let body = crate::surfaces::stitch(&bodies, tol)?;
+104 -6
View File
@@ -219,7 +219,7 @@ fn benchmark_independent_step_solids() {
let mut items: Vec<_> = ex.entities.iter().filter(|(_, e)| e.name() == "MANIFOLD_SOLID_BREP").map(|(id, _)| *id).collect();
items.sort();
let start = std::time::Instant::now();
let solids: Vec<_> = items.iter().map(|id| solidcraft_step::topology::read_solid(&ex, *id, Units::default()).unwrap()).collect();
let solids: Vec<_> = items.iter().map(|id| solidcore_step::topology::read_solid(&ex, *id, Units::default()).unwrap()).collect();
let read = start.elapsed();
let start = std::time::Instant::now();
for solid in solids {
@@ -1675,7 +1675,7 @@ fn knot_multiplicities_are_checked_before_they_are_expanded() {
};
let read = |text: &str| {
let ex = p21::parse(text).unwrap();
solidcraft_step::read::Reader::new(&ex, Default::default()).curve(3).map(|_| ())
solidcore_step::read::Reader::new(&ex, Default::default()).curve(3).map(|_| ())
};
// These diagnostics come from the checks before expansion; wall-clock time depends on CI load.
let error = read(&curve(1_000_000, 64)).unwrap_err();
@@ -2120,11 +2120,11 @@ fn step_conformance_parts_read_back_their_exact_shape() {
}
}
/// The volume of the one solid `solidcraft_step::topology` reads from `text` into schema 4,
/// The volume of the one solid `solidcore_step::topology` reads from `text` into schema 4,
/// measured as the body of that record.
pub(super) fn schema_4_volume(text: &str) -> f64 {
let ex = solidcraft_step::p21::parse(text).unwrap();
let r = solidcraft_step::topology::read_solids_with(&ex, &solidcraft_smlib::pcurves::SmlibTrims);
let ex = solidcore_step::p21::parse(text).unwrap();
let r = solidcore_step::topology::read_solids_with(&ex, &solidcraft_smlib::pcurves::SmlibTrims);
assert!(r.failures.is_empty() && r.solids.len() == 1, "{:?}", r.failures);
measure(&Body::from_record(r.solids[0].brep.clone()).unwrap()).unwrap().volume
}
@@ -2149,7 +2149,7 @@ fn step_conformance_parts_read_into_smlib() {
];
for (name, volume, faces) in parts {
let text = std::fs::read_to_string(dir.join(format!("{name}.step"))).unwrap();
let ex = solidcraft_step::p21::parse(&text).unwrap();
let ex = solidcore_step::p21::parse(&text).unwrap();
// The file's one representation context, as the import reads it.
let ctx = ex.entities.iter().find(|(_, e)| e.record("GLOBAL_UNIT_ASSIGNED_CONTEXT").is_some()).map(|(id, _)| *id);
let units = context_units(&ex, ctx, &mut HashMap::new());
@@ -2432,6 +2432,104 @@ fn can_with_its_lid_at(lid_z: f64) -> String {
)
}
/// A file whose only face cannot be made (its circle 1.5 mm off its plane) gives nothing to
/// open; the error names the face, why and how far, not only that the import failed.
#[test]
fn a_file_whose_only_face_cannot_be_made_names_it_and_how_far_off() {
let text = format!(
"{HEAD}
#1=CARTESIAN_POINT('',(0.,0.,10.));
#2=DIRECTION('',(0.,0.,1.));
#3=DIRECTION('',(1.,0.,0.));
#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);
#5=CARTESIAN_POINT('',(5.,0.,10.));
#6=VERTEX_POINT('',#5);
#7=CIRCLE('',#4,5.);
#8=EDGE_CURVE('',#6,#6,#7,.T.);
#9=ORIENTED_EDGE('',*,*,#8,.T.);
#10=EDGE_LOOP('',(#9));
#11=FACE_OUTER_BOUND('',#10,.T.);
#12=CARTESIAN_POINT('',(0.,0.,11.5));
#13=AXIS2_PLACEMENT_3D('',#12,#2,#3);
#14=PLANE('',#13);
#15=ADVANCED_FACE('Lid',(#11),#14,.T.);
#16=OPEN_SHELL('',(#15));
#17=SHELL_BASED_SURFACE_MODEL('Sheet',(#16));
#18=SHAPE_REPRESENTATION('',(#17),$);
{TAIL}"
);
let err = step_import(&text).err().map(|e| e.to_string()).unwrap_or_default();
assert!(err.contains("only face, #15") && err.contains("1.5") && err.contains("off the face's surface"), "{err}");
}
/// solidcore's step-brep read this Discord fillet operand (a reported part, its fillet the
/// subject of #2595) as one closed solid; SolidCraft's reader does too, its spline faces trimmed
/// by SMLib. Ported from step-brep's `tests/discord_fillet.rs`, with its fixture, now
/// solidcore-step's.
#[test]
fn discord_fillet_operand_reads_as_one_closed_solid() {
let text = include_str!("../../../../solidcore/crates/solidcore-step/tests/fixtures/discord-2026-10-30-fillet.step");
let imp = step_import(text).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let b = &imp.bodies[0];
assert!(b.closed && b.refused.is_none(), "{:?}", imp.warnings);
assert_eq!(b.body.face_count(), b.file_faces, "{:?}", imp.warnings);
}
/// A face no shell lists (a file of a bare face, as cadmpeg's root fixtures and STEP Tools'
/// faceted samples hold) is a one-face sheet. Ported from step-brep's
/// `tests/issue_2581_faceted_brep.rs` (`standalone_face_without_shell_is_single_face_sheet`).
#[test]
fn a_face_no_shell_lists_is_a_one_face_sheet() {
let text = format!(
"{HEAD}
#1=(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.));
#2=(GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNIT_ASSIGNED_CONTEXT((#1)) REPRESENTATION_CONTEXT('model','3D'));
#3=CARTESIAN_POINT('',(0.,0.,0.));
#4=CARTESIAN_POINT('',(9.,0.,0.));
#5=CARTESIAN_POINT('',(0.,7.,0.));
#6=POLY_LOOP('',(#3,#4,#5));
#7=FACE_OUTER_BOUND('',#6,.T.);
#8=DIRECTION('',(0.,0.,1.));
#9=DIRECTION('',(1.,0.,0.));
#10=AXIS2_PLACEMENT_3D('',#3,#8,#9);
#11=PLANE('',#10);
#12=FACE_SURFACE('',(#7),#11,.T.);
{TAIL}"
);
let imp = step_import(&text).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let b = &imp.bodies[0];
assert!(!b.closed && b.body.face_count() == 1, "{:?}", imp.warnings);
assert!(rel(measure(&b.body).unwrap().area, 31.5) < 1e-9, "{}", measure(&b.body).unwrap().area);
// Styled and on a layer, it is still a face no shell lists: a presentation's reference is not
// the topology's.
let styled = text.replace(
"#12=FACE_SURFACE('',(#7),#11,.T.);",
"#12=FACE_SURFACE('',(#7),#11,.T.);
#13=PRESENTATION_STYLE_ASSIGNMENT(());
#14=STYLED_ITEM('',(#13),#12);
#15=PRESENTATION_LAYER_ASSIGNMENT('1','',(#12));",
);
let imp = step_import(&styled).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
assert_eq!(imp.bodies[0].body.face_count(), 1, "{:?}", imp.warnings);
}
/// SMLib's refusal of a face by its index in the record (NIST CTC 02's face whose NURBS form is
/// not its analytic surface, which SMLib numbers 90) names the file's face, so the face is left
/// out and the import's warning says which: "face #1202".
#[test]
fn a_face_smlib_refuses_by_its_index_is_named_as_the_files_face() {
let msg = "SMLib: solidcore: flat arrays: face 2: a NURBS form that is not its analytic surface";
let named = super::parallel::name_record_face(msg, &[15, 20, 1202]);
assert_eq!(named, format!("face #1202: {msg}"));
assert_eq!(solidcore_step::topology::named_face(&named), Some(1202));
// An index past the record's faces, or no index, leaves the message as it is.
assert_eq!(super::parallel::name_record_face(msg, &[15]), msg);
assert_eq!(super::parallel::name_record_face("SMLib: no face here", &[15]), "SMLib: no face here");
}
/// A solid no B-rep can be made of comes in as an open surface body of its exact faces, the
/// faces that make it refused left out and named, as Poppy Humanoid's stock head (a face around
/// an open surface of revolution with one bound) and Congé19 (an edge further off its face than
+6 -6
View File
@@ -1,7 +1,7 @@
//! STEP (ISO 10303-21) export: AP242 product structure, assemblies, names and colours.
//!
//! Each body's record is written as it is, the exact B-rep, by
//! [`solidcraft_step::write::topology`]: its material regions as solids, its open shells as a
//! [`solidcore_step::write::topology`]: its material regions as solids, its open shells as a
//! surface model. Around it this writes one header and context, a product per part with its
//! shape representation, named solids, surface and face colours, and assemblies as
//! next-assembly-usage occurrences placed by item-defined transformations (the structure the
@@ -10,8 +10,8 @@
use std::fmt::Write as _;
use solidcraft_step::p21::Param;
use solidcraft_step::write::{GeomWriter, Writer, string, topology};
use solidcore_step::p21::Param;
use solidcore_step::write::{GeomWriter, Writer, string, topology};
use crate::body::Body;
use crate::{KernelError, Result};
@@ -161,7 +161,7 @@ pub fn step_export_products(products: &[ExportProduct], root: usize, header: &St
("GLOBAL_UNIT_ASSIGNED_CONTEXT", vec![refs(&[mm, rad, sr])]),
("REPRESENTATION_CONTEXT", vec![s("Context #1"), s("3D Context with UNIT and UNCERTAINTY")]),
]);
let origin = GeomWriter::new(&mut w).placement(&solidcraft_step::geom::Placement {
let origin = GeomWriter::new(&mut w).placement(&solidcore_step::geom::Placement {
origin: [0.0; 3],
axis: [0.0, 0.0, 1.0],
ref_direction: [1.0, 0.0, 0.0],
@@ -318,9 +318,9 @@ pub fn step_export_bodies(bodies: &[ExportBody], header: &StepHeader) -> Result<
mod tests {
use super::*;
use solidcore_record::Vec3;
use solidcore_step::p21;
use solidcore_step::write::real;
use solidcraft_geom::prelude::*;
use solidcraft_step::p21;
use solidcraft_step::write::real;
#[test]
fn islands_are_solids_of_their_own_not_voids() {
+11 -4
View File
@@ -701,11 +701,18 @@ const SAMPLES: usize = 64;
/// The parameter of the curve's point nearest `p` inside `interval`, and the distance: the
/// nearest of evenly spaced samples, then a golden-section search between its neighbours.
pub(crate) fn nearest_parameter(c: &CurveEval, [a, b]: [f64; 2], p: Vec3) -> Option<(f64, f64)> {
pub(crate) fn nearest_parameter(c: &CurveEval, interval: [f64; 2], p: Vec3) -> Option<(f64, f64)> {
nearest_parameter_in(c, interval, p, SAMPLES)
}
/// [`nearest_parameter`] from `samples` evenly spaced samples: a curve of many spans needs
/// several in each, or the search can settle beside the point on the wrong span.
pub(crate) fn nearest_parameter_in(c: &CurveEval, [a, b]: [f64; 2], p: Vec3, samples: usize) -> Option<(f64, f64)> {
let samples = samples.max(1);
let dist = |t: f64| c.point(t).map_or(f64::INFINITY, |q| q.dist(p));
let at = |k: usize| a + (b - a) * k as f64 / SAMPLES as f64;
let best = (0..=SAMPLES).min_by(|&i, &j| dist(at(i)).total_cmp(&dist(at(j))))?;
let (mut lo, mut hi) = (at(best.saturating_sub(1)), at((best + 1).min(SAMPLES)));
let at = |k: usize| a + (b - a) * k as f64 / samples as f64;
let best = (0..=samples).min_by(|&i, &j| dist(at(i)).total_cmp(&dist(at(j))))?;
let (mut lo, mut hi) = (at(best.saturating_sub(1)), at((best + 1).min(samples)));
let ratio = 0.5 * (5f64.sqrt() - 1.0);
for _ in 0..80 {
let (x, y) = (hi - ratio * (hi - lo), lo + ratio * (hi - lo));
+21
View File
@@ -2246,6 +2246,27 @@ fn boolean_identity_on_tangent_primitives() {
}
}
/// #1989: a boolean watched by someone who cancelled (an agent's request cancelled while it
/// computes off the window thread) does not start, saying so; unwatched, or watched and not
/// cancelled, it runs as before.
#[test]
fn a_cancelled_watch_stops_the_boolean() {
struct Cancelled(bool);
impl crate::watch::Watch for Cancelled {
fn cancelled(&self) -> bool {
self.0
}
fn progress(&self, _: &'static str, _: usize, _: usize) {}
}
let a = cylinder(Vec3::new(0.0, 0.0, -10.0), Vec3::Z, 3.0, 20.0).unwrap();
let b = cylinder(Vec3::new(-10.0, 0.0, 0.0), Vec3::X, 2.0, 20.0).unwrap();
let e = crate::watch::watching(std::sync::Arc::new(Cancelled(true)), || boolean(&a, &b, BoolOp::Intersect)).unwrap_err();
assert!(e.to_string().contains("cancelled"), "{e}");
let r = crate::watch::watching(std::sync::Arc::new(Cancelled(false)), || boolean(&a, &b, BoolOp::Intersect));
assert!(r.is_ok_and(|r| r.is_some()));
assert!(boolean(&a, &b, BoolOp::Intersect).unwrap().is_some());
}
/// A body in two pieces: booleans go a piece at a time.
#[test]
fn boolean_of_a_body_in_pieces() {
+5
View File
@@ -38,6 +38,11 @@ pub fn current() -> Option<Arc<dyn Watch>> {
CURRENT.with(|c| c.borrow().clone())
}
/// Whether the watch over this thread's work asked it to stop.
pub(crate) fn cancelled() -> bool {
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|w| w.cancelled()))
}
/// The error long work returns when its watch cancelled it.
pub(crate) fn cancelled_error(what: &str) -> crate::KernelError {
crate::KernelError::Failed(format!("{what} cancelled"))
+18 -9
View File
@@ -3,16 +3,12 @@ use super::*;
use solidcraft_theme::{AccentRamp, TabStyle, TabTable};
use solidcraft_ui_kit::{icons as kit_icons, theme_files, type_face};
/// The host's tab table, which is not the look's: one naming the tabs the built-in themes key
/// per-tab surfaces by (Onyx's `tabs`), which they are read against.
/// The host's tab table, which is not the look's: it has none of the tabs the built-in themes
/// key per-tab surfaces by (Onyx's `tabs`), and every built-in loads under it.
const TABS: TabTable = TabTable::new(&[
TabStyle { name: "MODEL", ramp: AccentRamp::Main, icon_accent: None },
TabStyle { name: "SKETCH", ramp: AccentRamp::Sketch, icon_accent: Some("sketch") },
TabStyle { name: "SURFACE", ramp: AccentRamp::Surface, icon_accent: Some("surface") },
TabStyle { name: "MESH", ramp: AccentRamp::Mesh, icon_accent: Some("mesh") },
TabStyle { name: "FORM", ramp: AccentRamp::Form, icon_accent: Some("form") },
TabStyle { name: "SHEET METAL", ramp: AccentRamp::Sheet, icon_accent: None },
TabStyle { name: "RENDER", ramp: AccentRamp::Render, icon_accent: None },
TabStyle { name: "DRAW", ramp: AccentRamp::Main, icon_accent: None },
TabStyle { name: "SHAPE", ramp: AccentRamp::Surface, icon_accent: Some("surface") },
TabStyle { name: "PAINT", ramp: AccentRamp::Render, icon_accent: None },
]);
#[test]
@@ -43,3 +39,16 @@ fn install_gives_the_kit_solidcrafts_icons_glyphs_fonts_and_themes() {
let heading = ctx.global_style().text_styles[&egui::TextStyle::Heading].family.clone();
assert_eq!(heading, type_face::semibold(1.0).family);
}
/// Under a host whose tab table has none of SolidCraft's tabs, every built-in theme loads, Onyx
/// without the per-tab surfaces it keys by SolidCraft's tab names.
#[test]
fn every_builtin_theme_loads_under_a_host_with_other_tabs() {
solidcraft_theme::install_tabs(TABS);
// The look's icon set, which the built-ins' icon palettes are read against.
install_tables();
let read = theme_files::read_builtin(themes::BUILTIN).unwrap_or_else(|e| panic!("the built-in themes read: {e}"));
assert_eq!(read.len(), themes::BUILTIN.len());
let onyx = read.iter().find(|theme| theme.id == "onyx").expect("Onyx is built in");
assert!(onyx.tabs.is_empty(), "{:?}", onyx.tabs.keys());
}
+3 -1
View File
@@ -1,5 +1,7 @@
//! SolidCraft's built-in themes: the theme files in `themes/`, embedded at build time (`build.rs`),
//! for the kit's theme files (`solidcraft_ui_kit::theme_files`).
//! for the kit's theme files (`solidcraft_ui_kit::theme_files`). Their per-tab surfaces (`tabs`) are
//! keyed by SolidCraft's tab names; under a host whose tab table lacks one, that tab's surfaces are
//! left out and the theme loads.
// `BUILTIN`, `(id, JSON text)` sorted by id.
include!(concat!(env!("OUT_DIR"), "/builtin_themes.rs"));
+21
View File
@@ -358,6 +358,24 @@ impl Remote {
outcome(&v).inspect_err(|_| self.drop_conn())
}
/// `control.cancel` for the request still running on this connection (a timed-out call's,
/// docs/control-protocol.md). Sent at once, without waiting for that request first, and never
/// on a fresh connection, which has nothing to cancel. Once the app no longer holds it
/// (removed, stopped or finished), later calls don't wait for it.
fn cancel_outstanding(&mut self, params: Value) -> Result<Value, String> {
if self.conn.is_none() {
return Ok(json!({"cancelled": null, "note": "nothing to cancel: no request of this bridge is running in the app"}));
}
let line = self.request_line("control.cancel", params);
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
let r = outcome(&v);
if r.is_ok() {
self.outstanding = None;
}
r
}
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
/// saying on stderr how long it takes. An error: the connection, and so the reply, was lost.
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, String> {
@@ -644,6 +662,8 @@ impl Backend for Remote {
"engine.execute" if self.param_check != ParamCheck::Off => self.execute_checked(params),
"engine.batch" if self.param_check != ParamCheck::Off => self.batch_checked(params),
"engine.script" if self.param_check != ParamCheck::Off => self.call_once(method, self.at_level(params), true),
// Not behind the request it cancels, as other calls are.
"control.cancel" => self.cancel_outstanding(params),
_ => self.call_once(method, params, true),
}
}
@@ -860,6 +880,7 @@ impl Headless {
// is ever left running.
"control.running" => Ok(json!({"running": []})),
"control.pending" => Ok(json!({"pending": null})),
"control.cancel" => Err("nothing can be cancelled in headless mode: each call runs to its end before it returns".into()),
other => Err(format!("unknown method `{other}`")),
}
}
+95
View File
@@ -3165,6 +3165,59 @@ fn a_bridge_made_before_the_app_runs_connects_once_it_does() {
app.join().unwrap();
}
/// #1989: the `cancel` tool sends `control.cancel` on the bridge's own connection, where the
/// request still running waits, and reports what the app did: stopped, or why it can't be
/// cancelled (a tool error). A bridge with no connection has nothing to cancel and sends nothing;
/// headless calls always run to their end, so it refuses there.
#[test]
fn cancel_forwards_to_the_app_and_reports() {
use std::io::{BufRead, BufReader, Write};
let text = |r: &Value| r["content"][0]["text"].as_str().unwrap_or_default().to_string();
let mut never = crate::Remote::new("127.0.0.1:9", Some("0123456789abcdef0123456789abcdef"));
let r = crate::call_tool(&mut never, "cancel", &json!({}));
assert!(r["isError"] != json!(true) && text(&r).contains("nothing to cancel"), "{r}");
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let app = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut lines = BufReader::new(stream.try_clone().unwrap());
let mut asked = Vec::new();
for reply in [
json!({"ok": true, "result": {"cancelled": 7, "method": "engine.execute", "outcome": "stopped", "note": "stopped: request 7 (`engine.execute`) was computing off the window thread; nothing was applied"}}),
json!({"ok": false, "cancellable": false, "error": "cannot be cancelled: request 8 (`engine.execute`) already runs in the app"}),
] {
let mut line = String::new();
lines.read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap();
asked.push((req["method"].clone(), req["token"].clone()));
let mut reply = reply;
reply["id"] = req["id"].clone();
writeln!(&stream, "{reply}").unwrap();
}
asked
});
let mut remote = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap();
let r = crate::call_tool(&mut remote, "cancel", &json!({}));
assert!(r["isError"] != json!(true), "{r}");
let body: Value = serde_json::from_str(&text(&r)).unwrap();
assert_eq!((&body["cancelled"], &body["outcome"]), (&json!(7), &json!("stopped")), "{body}");
let r = crate::call_tool(&mut remote, "cancel", &json!({}));
assert!(r["isError"] == json!(true) && text(&r).starts_with("cannot be cancelled"), "{r}");
let asked = app.join().unwrap();
assert!(asked.iter().all(|(m, t)| m == "control.cancel" && t == "0123456789abcdef0123456789abcdef"), "{asked:?}");
let mut s = server();
let r = raw_tool(&mut s, "cancel", json!({}));
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r["result"]["content"][0]["text"].as_str().is_some_and(|t| t.contains("headless")), "{r}");
let tools = crate::tools::tool_definitions();
let cancel = tools.iter().find(|t| t["name"] == "cancel").unwrap();
let a = &cancel["annotations"];
assert_eq!((&a["readOnlyHint"], &a["destructiveHint"], &a["openWorldHint"]), (&json!(false), &json!(false), &json!(false)), "{a}");
}
/// An app that quits while it runs a request may have applied it: the retry then finds nothing
/// listening, and the error says the call may have run, not just that the app is not running.
#[test]
@@ -3454,6 +3507,48 @@ fn running_sent_right_behind_a_batch_is_not_queued_behind_it() {
assert!(held, "the app was asked `control.running` while it held the batch");
}
/// #1989 with #1618: `cancel` goes out at once, not behind the request still running as other
/// calls are; once the app has removed or stopped it, later calls are sent without waiting for it
/// and `pending` has nothing. One that can't be cancelled is still waited for.
#[test]
fn cancel_frees_the_bridge_from_a_request_still_running() {
use crate::backend::{Backend, STILL_RUNNING};
use std::sync::atomic::{AtomicBool, Ordering};
let stuck = std::sync::Arc::new(AtomicBool::new(false));
let on_window = stuck.clone();
let (addr, seen) = scripted_app(move |req| match req["method"].as_str() {
Some("engine.execute") if req["params"]["command"] == "solid.fillet" => running(),
Some("control.pending") => json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": false}}),
Some("control.cancel") if on_window.load(Ordering::SeqCst) => {
json!({"ok": false, "cancellable": false, "error": "cannot be cancelled: request 1 already runs in the app"})
}
Some("control.cancel") => json!({"ok": true, "result": {"cancelled": 1, "method": "engine.execute", "outcome": "stopped"}}),
_ => json!({"ok": true, "result": "ran"}),
});
let mut remote = crate::Remote::connect(&addr).unwrap();
remote.set_waits(quick_waits(20, 20));
let methods = |seen: &std::sync::Mutex<(usize, Vec<String>)>| std::mem::take(&mut seen.lock().unwrap().1);
assert!(remote.call("engine.execute", json!({"command": "solid.fillet"})).unwrap_err().starts_with(STILL_RUNNING));
methods(&seen);
let r = crate::call_tool(&mut remote, "cancel", &json!({}));
assert_ne!(r["isError"], true, "{r}");
assert_eq!(methods(&seen), ["control.cancel"], "sent at once");
assert_eq!(remote.call("document.inspect", json!({})).unwrap(), "ran");
assert_eq!(methods(&seen), ["document.inspect"], "not behind the stopped request");
assert_eq!(remote.call("control.pending", json!({})).unwrap(), json!({"pending": null}));
// One the app can't cancel stays outstanding: the next call still waits for it.
stuck.store(true, Ordering::SeqCst);
assert!(remote.call("engine.execute", json!({"command": "solid.fillet"})).is_err());
methods(&seen);
let r = crate::call_tool(&mut remote, "cancel", &json!({}));
assert_eq!(r["isError"], true, "{r}");
let e = remote.call("document.inspect", json!({})).unwrap_err();
assert!(e.starts_with(STILL_RUNNING) && e.contains("earlier request"), "{e}");
assert!(!methods(&seen).iter().any(|m| m == "document.inspect"));
assert_eq!(seen.lock().unwrap().0, 1, "one connection throughout");
}
/// A request still running after the wait is left to the `pending` tool: the error says so
/// (and that it will still apply, or only write its file), later calls are not sent behind it, and `pending` returns
/// its result once it lands, on the same connection.
+17 -1
View File
@@ -363,10 +363,23 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
"Bridged to the app: the result of a request that was still running when its tool call returned (an error \
starting \"still running:\"). Waits for it up to 60 s: then its own result (or error), or done: false while \
it still runs (call again). pending: null when nothing is running. A request still running was not \
cancelled: don't send it again; other calls wait for it before they are sent.",
cancelled (`cancel` cancels it): don't send it again; other calls wait for it before they are sent.",
object(json!({}), &[]),
Effect::ReadOnly,
),
tool(
"cancel",
"Cancel",
"Bridged: cancel the request that was still running in the app when its call returned (an error starting \
\"still running:\"), without waiting for it. One still waiting its turn will not run; a design change or \
export computing off the app's window thread is stopped (Preferences > AI): the design stays as it was, \
though a stopped export may still write its file. One already applying on the window thread can't be \
cancelled: the error says so, and it will still finish. Only \
this server's own request can be cancelled. Headless calls always run to their end, so there is nothing to \
cancel.",
object(json!({}), &[]),
Effect::Edits,
),
tool("undo", "Undo", "Undo the last change to the design.", object(json!({}), &[]), Effect::Edits),
tool("redo", "Redo", "Redo the last undone change.", object(json!({}), &[]), Effect::Edits),
tool(
@@ -909,6 +922,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
},
"skill_reference" => crate::skill_reference::read(a),
"running" => b.call("control.running", json!({})),
"cancel" => b.call("control.cancel", json!({})),
"undo" => exec(b, "edit.undo", json!({})),
"redo" => exec(b, "edit.redo", json!({})),
"new_design" => exec(b, "file.new", pick(a, &["name"])),
@@ -1125,6 +1139,7 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option<Narrow>), String> {
"pending" => Err("`pending` is a tool, not a command: call it on its own".into()),
"running" => Err("`running` is a tool, not a command: call it on its own".into()),
"skill_reference" => Err("`skill_reference` is a tool, not a command: call it on its own".into()),
"cancel" => Err("`cancel` is a tool, not a command: call it on its own".into()),
// An engine command: the step goes as it is.
_ => Ok((String::new(), Value::Null, None)),
}
@@ -1134,6 +1149,7 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option<Narrow>), String> {
const TOOL_STEPS: &[&str] = &[
"batch",
"body_topology",
"cancel",
"check_parameters",
"compare_picture",
"crash_reports",
+77 -9
View File
@@ -24,6 +24,15 @@ const MERGE_TOL: f64 = 1e-6;
/// within it join only when each is the other's unique nearest end, so a third end as near
/// leaves the gap open.
pub const ENDPOINT_JOIN_TOL: f64 = MERGE_TOL * 100.0;
/// How far apart (mm) the ends of a line and an arc, or of two arcs, may be and still join when
/// each end lies on the other curve. Where a curve meets an arc tangentially, a point held on
/// both by "point on curve" constraints is only fixed to about `sqrt(2 * radius * residual)`
/// along the tangent: the solver's 1e-9 mm residual leaves the two ends up to 2.2e-4 mm apart
/// on a 25 mm arc (#14256). Half of [`ISLAND_MARGIN`], so joined curves share an island.
const TANGENT_JOIN_TOL: f64 = ISLAND_MARGIN * 0.5;
/// How near (mm) an end must lie to another curve's line or circle to count as on it for
/// [`TANGENT_JOIN_TOL`]: well above the solver's residual, well below any visible gap.
const ON_CARRIER_TOL: f64 = MERGE_TOL * 0.1;
const MAX_EDGES: usize = 50_000;
/// A closed region usable by features, with the ids of the curves bounding it.
@@ -484,10 +493,63 @@ fn close_line_joints(mut segs: Vec<Seg2>, tol: f64) -> Vec<Seg2> {
segs
}
/// Joins line and arc ends that lie close together. Returns false when there were too many ends
/// close together to look at them all (nothing is joined then).
/// The line or circle a profile line or arc lies on, from its exact geometry.
#[derive(Clone, Copy)]
enum Carrier {
Line { a: Vec2, b: Vec2 },
Circle { c: Vec2, r: f64 },
}
impl Carrier {
fn of(sk: &Sketch, ci: usize) -> Option<Carrier> {
match sk.curves.get(ci)?.kind {
CurveKind::Line { a, b } => {
let (a, b) = (sk.point(a)?, sk.point(b)?);
(a.dist(b) > MERGE_TOL).then_some(Carrier::Line { a, b })
}
CurveKind::Arc { .. } => match sk.segs(ci).into_iter().next()? {
Seg2::Arc { center, radius, .. } if center.is_finite() && radius.is_finite() => Some(Carrier::Circle { c: center, r: radius }),
_ => None,
},
_ => None,
}
}
fn distance(&self, p: Vec2) -> f64 {
match *self {
Carrier::Line { a, b } => {
let d = b - a;
(d.cross(p - a) / d.len().max(1e-12)).abs()
}
Carrier::Circle { c, r } => (p.dist(c) - r).abs(),
}
}
}
/// Whether the end `p` of a curve lying on `pc` and the end `q` of another curve lying on `qc`
/// meet where the two curves touch: each end lies on the other's line or circle. Two lines never
/// meet this way (an end slides freely along a line it lies on, so ends on a common line can be
/// genuinely apart), nor do two arcs of one circle.
fn ends_on_each_other(p: Vec2, pc: Carrier, q: Vec2, qc: Carrier) -> bool {
let distinct = match (pc, qc) {
(Carrier::Line { .. }, Carrier::Line { .. }) => false,
(Carrier::Circle { c, r }, Carrier::Circle { c: c2, r: r2 }) => !same_circle(c, r, c2, r2, MERGE_TOL),
_ => true,
};
distinct && qc.distance(p) <= ON_CARRIER_TOL && pc.distance(q) <= ON_CARRIER_TOL
}
/// Joins line and arc ends that lie close together: within [`ENDPOINT_JOIN_TOL`], or within
/// [`TANGENT_JOIN_TOL`] where each end lies on the other curve ([`ends_on_each_other`]). Returns
/// false when there were too many ends close together to look at them all (nothing is joined
/// then).
fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mut [usize], max_edges: usize) -> bool {
let tol = ENDPOINT_JOIN_TOL;
let tol = TANGENT_JOIN_TOL;
let mut carriers = std::collections::HashMap::<usize, Option<Carrier>>::new();
for &(_, curve_index) in endpoints {
carriers.entry(curve_index).or_insert_with(|| Carrier::of(sk, curve_index));
}
let carrier = |ci: usize| carriers.get(&ci).copied().flatten();
let cell = |p: Vec2| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64);
let mut curve_endpoints = std::collections::HashMap::<usize, Vec<usize>>::new();
for &(point_index, curve_index) in endpoints {
@@ -500,19 +562,19 @@ fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mu
Some(((*a).min(*b), (*a).max(*b)))
})
.collect();
let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> = std::collections::HashMap::new();
let mut grid: std::collections::HashMap<(i64, i64), Vec<(usize, usize)>> = std::collections::HashMap::new();
let mut joins = std::collections::HashMap::<(usize, usize), f64>::new();
let mut checks = 0usize;
let check_limit = max_edges.saturating_mul(16).max(16);
let mut exceeded_limit = false;
'points: for &(point_index, _) in endpoints {
'points: for &(point_index, curve_index) in endpoints {
let Some(point) = sk.point(point_index).filter(|point| point.is_finite()) else { continue };
let (cx, cy) = cell(point);
for dx in -1..=1 {
for dy in -1..=1 {
let (Some(x), Some(y)) = (cx.checked_add(dx), cy.checked_add(dy)) else { continue };
if let Some(candidates) = grid.get(&(x, y)) {
for &other_index in candidates {
for &(other_index, other_curve) in candidates {
if other_index == point_index {
continue;
}
@@ -521,7 +583,13 @@ fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mu
exceeded_limit = true;
break 'points;
}
if let Some(distance) = sk.point(other_index).map(|other| point.dist(other)).filter(|distance| *distance <= tol) {
let Some(other) = sk.point(other_index) else { continue };
let distance = point.dist(other);
let touching = || match (carrier(curve_index), carrier(other_curve)) {
(Some(pc), Some(qc)) => curve_index != other_curve && ends_on_each_other(point, pc, other, qc),
_ => false,
};
if distance <= ENDPOINT_JOIN_TOL || (distance <= tol && touching()) {
let pair = (point_index.min(other_index), point_index.max(other_index));
joins.entry(pair).and_modify(|best| *best = best.min(distance)).or_insert(distance);
}
@@ -529,7 +597,7 @@ fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mu
}
}
}
grid.entry((cx, cy)).or_default().push(point_index);
grid.entry((cx, cy)).or_default().push((point_index, curve_index));
}
if !exceeded_limit {
let mut nearest = std::collections::HashMap::<usize, (f64, Option<usize>)>::new();
@@ -1610,7 +1678,7 @@ fn island_loops(sk: &Sketch, curves: &[usize], max_edges: usize) -> Island {
if !ok || h != start {
continue;
}
let segs = close_graph_joints(segs, &edge_nodes, &edge_analytic, ENDPOINT_JOIN_TOL);
let segs = close_graph_joints(segs, &edge_nodes, &edge_analytic, TANGENT_JOIN_TOL);
let lp = Loop2 { segs: close_line_joints(restore_exact(segs, &ptags, &exact), ENDPOINT_JOIN_TOL) };
if lp.signed_area() > 1e-9 {
let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0);
+71
View File
@@ -540,6 +540,77 @@ fn profiles_join_unconstrained_nearby_line_arc_endpoints() {
assert!((profiles[0].area - (200.0 + PI * 25.0)).abs() < 1e-6, "{}", profiles[0].area);
}
/// The end plate of #14256: an outline whose 25 mm arc meets a horizontal and a vertical line
/// tangentially, each joint held by two "point on curve" constraints instead of one shared point.
/// The solver leaves the arc's ends on the lines only to its 1e-9 mm residual, which along the
/// tangent puts them 5e-5 mm and 2.25e-4 mm from the lines' ends. The horizontal line is raised
/// by `lift`.
fn tangent_joint_end_plate(lift: f64) -> Sketch {
let mut sk = Sketch::new();
let knee = (50.0_f64.powi(2) - 11.125_f64.powi(2)).sqrt();
let mut point = |x: f64, y: f64| sk.add_point(v(x, y), None).unwrap();
let top_left = point(-14.0, 90.0);
let top_right = point(14.0, 90.0);
let shoulder = point(14.0, 48.0);
let knee = point(knee, 11.125);
let right_top = point(90.0, 11.125);
let right_bottom = point(90.0, -11.125);
let step_bottom = point(25.0, -11.125);
let step_top = point(25.0, 0.0);
let arc_start = point(24.999999999949498, 5.025035674136865e-5);
let arc_end = point(0.0002252128472149822, 24.999999999324313);
let ledge_right = point(0.0, 25.0 + lift);
let ledge_left = point(-14.0, 25.0 + lift);
let big_centre = point(0.0, 0.0);
let small_centre = point(0.0, 0.0);
sk.add_line_pts(top_left, top_right, None).unwrap();
sk.add_line_pts(top_right, shoulder, None).unwrap();
sk.add_arc(v(0.0, 0.0), sk.points[knee].pos, sk.points[shoulder].pos, [Some(big_centre), Some(knee), Some(shoulder)], None).unwrap();
sk.add_line_pts(right_top, knee, None).unwrap();
sk.add_line_pts(right_bottom, right_top, None).unwrap();
sk.add_line_pts(step_bottom, right_bottom, None).unwrap();
sk.add_line_pts(step_top, step_bottom, None).unwrap();
sk.add_arc(v(0.0, 0.0), sk.points[arc_start].pos, sk.points[arc_end].pos, [Some(small_centre), Some(arc_start), Some(arc_end)], None).unwrap();
sk.add_line_pts(ledge_left, ledge_right, None).unwrap();
sk.add_line_pts(top_left, ledge_left, None).unwrap();
sk
}
#[test]
fn profiles_join_line_and_arc_ends_that_lie_on_each_other_at_a_tangent_joint() {
let profiles = find_profiles(&tangent_joint_end_plate(0.0));
assert_eq!(profiles.len(), 1, "{profiles:?}");
assert!(profiles[0].region.outer.is_closed(1e-9), "{:?}", profiles[0].region.outer.segs);
assert_eq!(profiles[0].outer_curves.len(), 10, "{:?}", profiles[0].outer_curves);
// The polygon through the corners, plus the segment the 50 mm arc adds, less the quarter
// disc's segment the 25 mm arc takes away.
let knee = (50.0_f64.powi(2) - 11.125_f64.powi(2)).sqrt();
let corners = [
(25.0, -11.125),
(90.0, -11.125),
(90.0, 11.125),
(knee, 11.125),
(14.0, 48.0),
(14.0, 90.0),
(-14.0, 90.0),
(-14.0, 25.0),
(0.0, 25.0),
(25.0, 0.0),
];
let polygon = (0..corners.len()).map(|i| {
let ((x0, y0), (x1, y1)) = (corners[i], corners[(i + 1) % corners.len()]);
x0 * y1 - x1 * y0
});
let segment = |r: f64, sweep: f64| r * r / 2.0 * (sweep - sweep.sin());
let area = polygon.sum::<f64>() / 2.0 + segment(50.0, 48.0_f64.atan2(14.0) - 11.125_f64.atan2(knee)) - segment(25.0, FRAC_PI_2);
assert!((profiles[0].area - area).abs() < 1e-6, "{} vs {area}", profiles[0].area);
// Raised 1e-5 mm, the line's end is off the arc's circle and the arc's end off the line:
// the 2.5e-4 mm gap is a real one and the outline stays open.
let open = find_profiles(&tangent_joint_end_plate(1e-5));
assert!(open.is_empty(), "{open:?}");
}
#[test]
fn removal_and_references() {
let mut sk = Sketch::new();
-5
View File
@@ -88,7 +88,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.proto_vertex_miss_is_false = true;
f.check_loop_vertex_tolerance = true;
f.check_loop_projected_seam_ends = true;
f.trim_boundary_seam_wrap = true;
f.bspline_check_min_radius = true;
f.oscillating_skips_failed_guesses = true;
f.refine_cc_range_ends = true;
@@ -104,7 +103,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.offset_walls_touch_refused = true;
f.offset_analytic_mate_edges = true;
f.nurb_cylinder_inside_out_frame = true;
f.shell_inner_shell_of_statuses = true;
f.local_operation_refuses_loose_tangent_edges = true;
f.torus_major_radius_from_origin = true;
f.fillet_corner_sphere_from_four_arcs = true;
@@ -159,7 +157,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.found_torus_sphere_keep_tested_nurb = true;
f.boolean_skips_faces_apart = true;
f.uv_trim_checks_on_surface = true;
f.loop_orientation_floor_from_loop_box = true;
f.local_operation_box_holds_new_surfaces = true;
f.uv_trim_joins_drop_pieces = true;
f.shell_edges_on_analytics_exact = true;
@@ -197,7 +194,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.loops_go_on_round_the_vertex = true;
f.revolve_splits_curves_on_axis = true;
f.cover_wraps_across_closed_seam = true;
f.imprint_extension_keeps_analytic_curve = true;
f.fillet_trace_from_tangent_point_toward_next = true;
f.fillet_tangent_point_one_sided_step = true;
f.fillet_rails_meet_at_one_end_point = true;
@@ -306,7 +302,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.curve_sweep_closed_path_joins_ends = true;
f.validate_tolerances_fixes_reported_gaps = true;
f.edgeuse_convexity_propagates_step_off_failure = true;
f.is_iso_curve_at_propagates_canonical_failure = true;
f.database_io_byte_swaps_read_boolean_and_u16 = true;
f.is_topological_vertex_rejects_cusps_and_tangent_errors = true;
f.cone_create_canonical_preserves_inside_out_and_checks_height = true;
+8
View File
@@ -79,6 +79,7 @@ pub(super) fn status(e: SolidError) -> Error {
e if is_b_spline_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
e if is_intersect_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
e if is_primitive_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
e if is_mass_heal_or_geom_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
SolidError::InvalidInput { .. } => Error::Status(INVALID_INPUT),
SolidError::Unsupported { reason, .. } if reason == MIXED_CORNER_RAILS_CROSS => Error::Unported(MIXED_CORNER_UNSUPPORTED),
e if is_boolean_failure(&e) => boolean_status(e),
@@ -126,6 +127,13 @@ fn is_primitive_failure(e: &SolidError) -> bool {
e.primitive_failure().is_some()
}
/// A mass-property, healing or geometry-utility failure (solidcore's `MassFailure`,
/// `HealFailure`, `GeomFailure`): it stands for the status the routine returned (`Upstream`
/// before solidcore described it), and passes on as that status.
fn is_mass_heal_or_geom_failure(e: &SolidError) -> bool {
e.mass_failure().is_some() || e.heal_failure().is_some() || e.geom_failure().is_some()
}
/// `size_ok`.
fn size_ok(x: f64) -> bool {
x.is_finite() && x > 0.0 && x <= 1e6
+14
View File
@@ -562,4 +562,18 @@ fn a_boolean_failure_from_another_operation_keeps_its_status() {
assert!(matches!(status(primitive(PrimitiveFailure::AxisInsideFace)), Error::Status(1025)));
let helix = PrimitiveFailure::HelixDeviates { deviation: 0.5, tolerance: 0.25 };
assert!(matches!(status(primitive(helix)), Error::Status(1021)));
// a mass-property, healing or geometry-utility failure passes on as the status it replaced
use solidcore::error::geom::GeomFailure;
use solidcore::error::heal::HealFailure;
use solidcore::error::mass::MassFailure;
let mass = |f| Failure::new("mass::integrator", f).into_error();
assert!(matches!(status(mass(MassFailure::StepControl)), Error::Status(1001)));
assert!(matches!(status(mass(MassFailure::Missing("the edge's curve"))), Error::Status(1006)));
let heal = |f| Failure::new("heal::stitch::p1", f).into_error();
assert!(matches!(status(heal(HealFailure::Failed("the edges could not be glued"))), Error::Status(1001)));
assert!(matches!(status(heal(HealFailure::NotYetHealable)), Error::Status(1040)));
let geom = |f| Failure::new("geom::matrix", f).into_error();
assert!(matches!(status(geom(GeomFailure::Failed("the matrix is singular"))), Error::Status(1001)));
assert!(matches!(status(geom(GeomFailure::CoincidentPoints)), Error::Status(0)));
assert!(matches!(status(geom(GeomFailure::ToleranceTooSmall { tolerance: 0.0 })), Error::Status(1007)));
}
+2 -1
View File
@@ -2274,7 +2274,8 @@ fn flat_to_brep_data(k: &Kernel, f: &In<'_>, s: &Shape) -> Result<BrepData> {
i_cone += usize::from(ty == SURFACE_CONE_I);
i_torus += usize::from(ty == SURFACE_TORUS_I);
let nurbs = bspline_surface_3d(f, &mut nurbs_cursor)?;
analytic_of_nurbs(k, &nurbs, ty, xsect)?.ok_or_else(|| bad("a NURBS form that is not its analytic surface"))?
analytic_of_nurbs(k, &nurbs, ty, xsect)?
.ok_or_else(|| Error::Solidcore(format!("flat arrays: face {i}: a NURBS form that is not its analytic surface")))?
} else if ty == SURFACE_SPHERE_I {
let j = i_sphere;
i_sphere += 1;
+33 -16
View File
@@ -1467,20 +1467,30 @@ fn cylinder_piece(k: &mut Kernel, b: &mut Brep, m: &Screw, a: Point3, e: Point3,
})
}
/// `screw_wall`: the wall one profile curve sweeps over `st`: across the curve its NURBS, across
/// the turns each control point's path, the weights the curve's times the arcs'.
fn screw_wall(m: &Screw, c: &CurveCanonical, st: &Stations) -> Out<Surface> {
let across = 2 * st.spans() + 1;
/// `screw_wall`: the wall one profile curve sweeps over consecutive station pieces: across the
/// curve its NURBS, across the turns each control point's path. Cylinder seams split the pieces,
/// not this wall: join their existing nets at the shared endpoint, without refitting the spans.
fn screw_wall(m: &Screw, c: &CurveCanonical, pieces: &[Stations]) -> Out<Surface> {
let across = 2 * pieces.iter().map(Stations::spans).sum::<usize>() + 1;
let mut net = Vec::with_capacity(c.control_points.len() * across);
let mut weights = Vec::with_capacity(net.capacity());
for (l, &p) in c.control_points.iter().enumerate() {
let w = c.weights.get(l).copied().unwrap_or(1.0);
for (j, q) in screw_path(m, p, st).into_iter().enumerate() {
net.push(q);
weights.push(w * st.weight(j));
for (i, st) in pieces.iter().enumerate() {
for (j, q) in screw_path(m, p, st).into_iter().enumerate().skip(usize::from(i > 0)) {
net.push(q);
weights.push(w * st.weight(j));
}
}
}
let (vknots, vmult) = st.knots();
let vknots: Vec<f64> = pieces.iter().enumerate().flat_map(|(i, st)| st.t.iter().copied().skip(usize::from(i > 0))).collect();
let mut vmult = vec![2; vknots.len()];
if let Some(first) = vmult.first_mut() {
*first = 3;
}
if let Some(last) = vmult.last_mut() {
*last = 3;
}
let data = CanonicalSurfaceData {
u_degree: c.degree,
v_degree: 2,
@@ -1531,7 +1541,7 @@ fn screw_placed(m: &Screw, c: &Profile, a: f64) -> Profile {
/// by span (SM_API's curve sweep carries the profile on its own frame, which along a helix turns
/// it away from the screw motion), lines parallel to the axis as exact cylinder pieces, the caps
/// where the motion starts and ends, all stitched and oriented.
fn screw(k: &mut Kernel, profile: &[Profile], mut m: Screw, turns: f64, tolerance: f64) -> solidcore::Result<Out<Brep>> {
fn screw(k: &mut Kernel, profile: &[Profile], mut m: Screw, turns: f64, tolerance: f64, join_spline_walls: bool) -> solidcore::Result<Out<Brep>> {
let curves = match parse(k, profile) {
Ok(c) => c,
Err(e) => return Ok(Err(e)),
@@ -1576,7 +1586,8 @@ fn screw(k: &mut Kernel, profile: &[Profile], mut m: Screw, turns: f64, toleranc
}
}
// Walls of lines parallel to the axis are exact cylinders, which cannot turn on themselves:
// then every wall comes in pieces of half a turn (the last up to an eighth longer).
// their faces come in pieces of half a turn (the last up to an eighth longer). All walls use
// these stations so their shared boundary curves agree, but spline walls keep one face.
let mut radius = Vec::with_capacity(curves.len());
for c in &curves {
radius.push(axial_radius(k, &m, c, tolerance)?);
@@ -1622,17 +1633,22 @@ fn screw(k: &mut Kernel, profile: &[Profile], mut m: Screw, turns: f64, toleranc
pieces.push(Stations::new(from, to, n as usize));
}
let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
for st in &pieces {
// A radial law's knots can change the wall's tangent, so retain those face boundaries. Half
// turns are only cylinder seams: join the other walls' spans when explicitly requested.
for group in pieces.chunk_by(|a, _| join_spline_walls && !m.angles.contains(&a.last())) {
for (i, (c, wall)) in curves.iter().zip(&walls).enumerate() {
let r = radius.get(i).copied().unwrap_or(0.0);
if r > 0.0 && m.offset(st.first()) == m.offset(st.last()) {
let (Some(first), Some(last)) = (group.first(), group.last()) else { continue };
if r > 0.0 && m.offset(first.first()) == m.offset(last.last()) {
let (a, e) = ends(k, c)?;
if let Err(err) = cylinder_piece(k, &mut b, &m, a, e, r + m.offset(st.first()), st)? {
return Ok(Err(err));
for st in group {
if let Err(err) = cylinder_piece(k, &mut b, &m, a, e, r + m.offset(st.first()), st)? {
return Ok(Err(err));
}
}
continue;
}
let surface = match screw_wall(&m, wall, st) {
let surface = match screw_wall(&m, wall, group) {
Ok(s) => s,
Err(err) => return Ok(Err(err)),
};
@@ -1671,6 +1687,7 @@ pub(crate) fn screw_sweep(
turns: f64,
law: Option<&[(f64, f64)]>,
tolerance: f64,
join_spline_walls: bool,
) -> Result<Body> {
if !screw_ok(origin, pitch, turns, tolerance) {
return Err(Error::Argument);
@@ -1698,7 +1715,7 @@ pub(crate) fn screw_sweep(
angles: law.iter().map(|(t, _)| TAU * t).collect(),
offsets: law.iter().map(|(_, o)| *o).collect(),
};
screw(k, profile, m, turns, tolerance)
screw(k, profile, m, turns, tolerance, join_spline_walls)
})
}
+10 -6
View File
@@ -299,8 +299,8 @@ fn on_core(op: &Op) -> crate::Result<Body> {
Op::Loft(s, d, cap) => loft(s, *d, *cap),
Op::LoftToPoint(s, apex) => loft_to_point(s, *apex),
Op::Stations(p, s, e) => sweep_stations(p, s, *e),
Op::Screw(p, o, a, pitch, turns, tol) => screw_sweep(p, *o, *a, *pitch, *turns, None, *tol),
Op::ScrewRadial(p, o, a, pitch, turns, law, tol) => screw_sweep(p, *o, *a, *pitch, *turns, Some(law), *tol),
Op::Screw(p, o, a, pitch, turns, tol) => screw_sweep(p, *o, *a, *pitch, *turns, None, *tol, false),
Op::ScrewRadial(p, o, a, pitch, turns, law, tol) => screw_sweep(p, *o, *a, *pitch, *turns, Some(law), *tol, false),
}
}
@@ -610,10 +610,14 @@ fn hostile_arguments_are_refused() {
let flat = Affine { m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]], t: [0.0; 3] };
assert_eq!(sweep_stations(&sq, &[translate([0.0; 3]), flat], Ends::Capped).err(), Some(Error::Argument), "a singular map");
assert_eq!(sweep_stations(&sq, &[translate([0.0; 3])], Ends::Capped).err(), Some(Error::Argument), "one station");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 0.0, 1.0, None, 1e-3).err(), Some(Error::Argument), "no pitch");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 300.0, None, 1e-3).err(), Some(Error::Argument), "too many turns");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[(0.5, 0.0), (0.2, 0.0)]), 1e-3).err(), Some(Error::Argument), "a law going back");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[]), 1e-3).err(), Some(Error::Argument), "an empty law");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 0.0, 1.0, None, 1e-3, false).err(), Some(Error::Argument), "no pitch");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 300.0, None, 1e-3, false).err(), Some(Error::Argument), "too many turns");
assert_eq!(
screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[(0.5, 0.0), (0.2, 0.0)]), 1e-3, false).err(),
Some(Error::Argument),
"a law going back"
);
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[]), 1e-3, false).err(), Some(Error::Argument), "an empty law");
let b = make_box([0.0; 3], [1.0; 3]).unwrap();
assert_eq!(project_curves(&b, [0.5; 3], 0.01, &sq, [0.0; 3], 1e-6).err(), Some(Error::Argument), "no direction");
assert_eq!(project_curves(&b, [0.5; 3], 0.0, &sq, Z, 1e-6).err(), Some(Error::Argument), "no pick tolerance");
+35 -2
View File
@@ -11,6 +11,17 @@ use serde_json::json;
use crate::trace;
use crate::{Brep, Result, core};
/// Screw-sweep topology and its approximation tolerance.
#[derive(Clone, Copy, Debug)]
pub struct ScrewSweepOptions<'a> {
/// Optional radial law as `(turn, offset)` pairs.
pub law: Option<&'a [(f64, f64)]>,
/// Join spline walls across cylinder seams; false retains the historical faces.
pub join_spline_walls: bool,
/// Maximum wall approximation error, mm.
pub tolerance: f64,
}
/// A 3D curve of a profile, path or section.
#[derive(Clone, Debug, PartialEq)]
pub enum Curve {
@@ -247,7 +258,7 @@ impl Brep {
/// `turns` right-handed turns about it, rising `pitch` (signed) per turn, capped at both ends,
/// its walls within about `tolerance` of the true screw surfaces.
pub fn screw_sweep(profile: &[Curve], origin: [f64; 3], direction: [f64; 3], pitch: f64, turns: f64, tolerance: f64) -> Result<Brep> {
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, None, tolerance).map(Brep::made_core);
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, None, tolerance, false).map(Brep::made_core);
trace::made(
"screw_sweep",
&[],
@@ -272,13 +283,35 @@ impl Brep {
law: &[(f64, f64)],
tolerance: f64,
) -> Result<Brep> {
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, Some(law), tolerance).map(Brep::made_core);
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, Some(law), tolerance, false).map(Brep::made_core);
trace::made("screw_sweep", &[], &r, || {
json!({"profile": trace::curves(profile), "origin": origin, "direction": direction, "pitch": pitch, "turns": turns,
"law": law, "tolerance": tolerance})
});
r
}
/// A screw sweep with explicit face layout. The historical entry points retain
/// half-turn faces; new thread features request continuous spline walls here.
pub fn screw_sweep_with_options(
profile: &[Curve],
origin: [f64; 3],
direction: [f64; 3],
pitch: f64,
turns: f64,
options: ScrewSweepOptions<'_>,
) -> Result<Brep> {
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, options.law, options.tolerance, options.join_spline_walls)
.map(Brep::made_core);
trace::made("screw_sweep", &[], &r, || {
json!({
"profile": trace::curves(profile), "origin": origin, "direction": direction,
"pitch": pitch, "turns": turns, "law": options.law,
"tolerance": options.tolerance, "join_spline_walls": options.join_spline_walls,
})
});
r
}
}
/// A sweep frame's name in a trace.
+33 -1
View File
@@ -96,9 +96,41 @@ fn sweeps_lofts_and_screws() {
let tooth = polygon(&[[4.0, 0.0, 0.0], [4.4, 0.0, 0.0], [4.4, 0.0, 0.4], [4.0, 0.0, 0.4]]);
let b = Brep::screw_sweep(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, 1e-4).unwrap();
assert_close(solid_volume(&b, "screw"), 0.16 * TAU * 4.2 * 3.0, 1e-4, "screw sweep");
let historical = b.to_native().unwrap();
assert!(historical.faces.iter().filter(|f| matches!(f.surface, solidcore_record::brep::Surface::RationalSpline { .. })).count() > 2);
let options = crate::sweeps::ScrewSweepOptions { law: None, join_spline_walls: true, tolerance: 1e-4 };
let b = Brep::screw_sweep_with_options(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, options).unwrap();
let record = b.to_native().unwrap();
let flanks = record.faces.iter().filter(|f| matches!(f.surface, solidcore_record::brep::Surface::RationalSpline { .. })).count();
assert_eq!(flanks, 2, "each screw flank must remain one face across the cylinder seams");
// The tooth moved out 1 mm over its three turns, linearly: A 2 pi (c turns + int offset dt).
let b = Brep::screw_sweep_radial(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, &[(0.0, 0.0), (3.0, 1.0)], 1e-4).unwrap();
let b = Brep::screw_sweep_with_options(
&tooth,
[0.0; 3],
[0.0, 0.0, 1.0],
1.0,
3.0,
crate::sweeps::ScrewSweepOptions { law: Some(&[(0.0, 0.0), (3.0, 1.0)]), join_spline_walls: true, tolerance: 1e-4 },
)
.unwrap();
assert_close(solid_volume(&b, "radial screw"), 0.16 * TAU * (4.2 * 3.0 + 1.5), 1e-4, "radial screw sweep");
assert_eq!(b.to_native().unwrap().faces.len(), 6, "a linear radial law needs four continuous walls and two caps");
// Two constant intervals around a sloping interval, with a partial last turn: keep the
// law's corners as face boundaries, while joining the half-turn pieces within each interval.
let law = [(0.0, 0.0), (0.65, 0.0), (1.9, 0.4), (3.2, 0.4)];
let b = Brep::screw_sweep_with_options(
&tooth,
[0.0; 3],
[0.0, 0.0, 1.0],
1.0,
3.2,
crate::sweeps::ScrewSweepOptions { law: Some(&law), join_spline_walls: true, tolerance: 1e-4 },
)
.unwrap();
assert!(b.check(CheckLevel::All).unwrap().is_valid());
let record = b.to_native().unwrap();
let splines = record.faces.iter().filter(|f| matches!(f.surface, solidcore_record::brep::Surface::RationalSpline { .. })).count();
assert_eq!(splines, 8, "two flanks across three law intervals and two sloping radial walls");
assert_eq!(Brep::screw_sweep_radial(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, &[(2.0, 0.0), (1.0, 1.0)], 1e-4).err(), Some(Error::Argument));
}
+13 -1
View File
@@ -95,7 +95,7 @@ const NO_KERNEL: [&str; 14] = [
"hold_a_kernel",
];
const ANSWERING: [&str; 63] = [
const ANSWERING: [&str; 64] = [
"set_dump_dir",
"current_call",
"crash_test",
@@ -159,6 +159,7 @@ const ANSWERING: [&str; 63] = [
"loft_to_point",
"screw_sweep",
"screw_sweep_radial",
"screw_sweep_with_options",
];
/// Every `pub fn` of the files that hold the crate's kernel entry points is accounted for.
@@ -278,6 +279,17 @@ fn every_entry_point_answers() {
says("loft_to_point", Brep::loft_to_point(&lines, p(5.0, 5.0, 12.0)));
says("screw_sweep", Brep::screw_sweep(&lines, p(30.0, 0.0, 0.0), p(0.0, 0.0, 1.0), 12.0, 1.0, 0.01));
says("screw_sweep_radial", Brep::screw_sweep_radial(&lines, p(30.0, 0.0, 0.0), p(0.0, 0.0, 1.0), 12.0, 1.0, &[(0.0, 0.0), (1.0, 0.0)], 0.01));
says(
"screw_sweep_with_options",
Brep::screw_sweep_with_options(
&lines,
p(30.0, 0.0, 0.0),
p(0.0, 0.0, 1.0),
12.0,
1.0,
crate::sweeps::ScrewSweepOptions { law: None, join_spline_walls: true, tolerance: 0.01 },
),
);
let _ = std::fs::remove_dir_all(&dir);
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "solidcraft-step"
description = "SolidCraft STEP (ISO 10303) exchange: the Part 21 exchange structure and Part 42 geometry as plain data, with no modelling backend"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solidcore-record = { workspace = true }
solidcore-ops = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-profile = { workspace = true }
-18
View File
@@ -1,18 +0,0 @@
//! STEP (ISO 10303) for SolidCraft, independent of any modelling backend. [`p21`] reads the
//! exchange structure, [`read`] turns its geometry entities into the plain curves and surfaces
//! of [`geom`], and [`write`] writes them back. Written from the public ISO 10303-21 and -42
//! specifications. [`topology`] reads solids (vertices to shells) into the kernel-neutral B-rep,
//! schema 4, and [`units`] the units of a representation context.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod eval;
pub mod geom;
pub mod p21;
pub mod read;
pub mod topology;
pub mod units;
pub mod write;
#[cfg(test)]
mod geom_tests;
-735
View File
@@ -1,735 +0,0 @@
//! ISO 10303-21 ("Part 21") exchange structure parser: the HEADER and the DATA section's entity
//! instances, simple (`#1=NAME(...)`) and complex (`#2=(A(...) B(...))`). Written from the
//! public standard. Input is hostile: sizes, entity counts and nesting depth are capped and every
//! error is a message, never a panic.
use std::cell::Cell;
use std::collections::HashMap;
/// Largest STEP text we parse.
pub const MAX_BYTES: usize = 512 << 20;
/// Most entity instances in one file.
pub const MAX_ENTITIES: usize = 20_000_000;
/// Deepest parameter list nesting.
const MAX_DEPTH: usize = 64;
/// A parameter value.
#[derive(Clone, Debug, PartialEq)]
pub enum Param {
Int(i64),
Real(f64),
Str(String),
/// `.NAME.` (`.T.`/`.F.` included).
Enum(String),
Ref(u64),
List(Vec<Param>),
/// `NAME(params)` (a typed value such as `LENGTH_MEASURE(1.)`).
Typed(String, Vec<Param>),
Binary(String),
/// `$`
Null,
/// `*`
Derived,
}
impl Param {
pub fn as_f64(&self) -> Option<f64> {
match self {
Param::Real(x) => Some(*x).filter(|x| x.is_finite()),
Param::Int(i) => Some(*i as f64),
Param::Typed(_, v) if v.len() == 1 => v.first().and_then(Param::as_f64),
_ => None,
}
}
pub fn as_i64(&self) -> Option<i64> {
match self {
Param::Int(i) => Some(*i),
Param::Real(x) if x.fract() == 0.0 && x.abs() < 9e15 => Some(*x as i64),
_ => None,
}
}
pub fn as_ref_id(&self) -> Option<u64> {
match self {
Param::Ref(r) => Some(*r),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Param::Str(s) => Some(s),
_ => None,
}
}
pub fn as_enum(&self) -> Option<&str> {
match self {
Param::Enum(s) => Some(s),
_ => None,
}
}
/// `.T.` → true, `.F.` → false (`.U.` and anything else → None).
pub fn as_bool(&self) -> Option<bool> {
match self.as_enum()? {
"T" => Some(true),
"F" => Some(false),
_ => None,
}
}
pub fn as_list(&self) -> Option<&[Param]> {
match self {
Param::List(v) => Some(v),
_ => None,
}
}
}
/// One record of an entity instance: a simple instance has one, a complex one several.
#[derive(Clone, Debug, PartialEq)]
pub struct Record {
pub name: String,
pub params: Vec<Param>,
}
/// An entity instance.
#[derive(Clone, Debug, PartialEq)]
pub struct Entity {
pub records: Vec<Record>,
}
impl Entity {
pub fn is_complex(&self) -> bool {
self.records.len() > 1
}
/// Name of a simple instance (the first record of a complex one).
pub fn name(&self) -> &str {
self.records.first().map(|r| r.name.as_str()).unwrap_or("")
}
/// Parameters of the record called `name`.
pub fn record(&self, name: &str) -> Option<&[Param]> {
self.records.iter().find(|r| r.name == name).map(|r| r.params.as_slice())
}
pub fn has(&self, name: &str) -> bool {
self.records.iter().any(|r| r.name == name)
}
/// Parameters of a simple instance.
pub fn params(&self) -> &[Param] {
self.records.first().map(|r| r.params.as_slice()).unwrap_or(&[])
}
}
/// A parsed exchange file.
#[derive(Clone, Debug, Default)]
pub struct Exchange {
/// FILE_SCHEMA names.
pub schemas: Vec<String>,
/// FILE_NAME originating system.
pub originating_system: String,
/// FILE_NAME preprocessor version.
pub preprocessor: String,
/// FILE_DESCRIPTION text, its lines joined by a space.
pub description: String,
pub entities: HashMap<u64, Entity>,
/// Ids defined more than once (the first definition is kept).
pub duplicates: Vec<u64>,
}
impl Exchange {
pub fn get(&self, id: u64) -> Option<&Entity> {
self.entities.get(&id)
}
}
struct Lexer<'a> {
s: &'a [u8],
i: usize,
/// The last place `line` counted to and the newlines before it, so errors met one after
/// another while skipping bad header records count each byte once.
counted: Cell<(usize, usize)>,
}
fn err<T>(msg: impl Into<String>) -> Result<T, String> {
Err(msg.into())
}
impl<'a> Lexer<'a> {
fn line(&self) -> usize {
let (at, before) = self.counted.get();
let (from, before) = if self.i >= at { (at, before) } else { (0, 0) };
let Some(b) = self.s.get(from..self.i) else { return 0 };
let newlines = before + b.iter().filter(|c| **c == b'\n').count();
self.counted.set((self.i, newlines));
newlines + 1
}
fn fail<T>(&self, msg: &str) -> Result<T, String> {
err(format!("STEP syntax error at line {}: {msg}", self.line()))
}
fn peek(&self) -> Option<u8> {
self.s.get(self.i).copied()
}
/// Skip whitespace and comments.
fn ws(&mut self) -> Result<(), String> {
loop {
match self.peek() {
Some(c) if c.is_ascii_whitespace() => self.i += 1,
Some(b'/') if self.s.get(self.i + 1) == Some(&b'*') => {
let rest = self.s.get(self.i + 2..).unwrap_or(&[]);
match rest.windows(2).position(|w| w == b"*/") {
Some(p) => self.i += p + 4,
None => return self.fail("unterminated comment"),
}
}
_ => return Ok(()),
}
}
}
fn eat(&mut self, c: u8) -> Result<bool, String> {
self.ws()?;
if self.peek() == Some(c) {
self.i += 1;
Ok(true)
} else {
Ok(false)
}
}
fn expect(&mut self, c: u8) -> Result<(), String> {
if self.eat(c)? { Ok(()) } else { self.fail(&format!("expected `{}`", c as char)) }
}
/// Where the next line after `from` that starts with a keyword begins.
fn next_header_line(&self, from: usize) -> Option<usize> {
let rest = self.s.get(from..)?;
let mut at = rest.iter().position(|&c| c == b'\n')? + 1;
loop {
let line = at + rest.get(at..)?.iter().take_while(|c| matches!(c, b' ' | b'\t' | b'\r')).count();
if rest.get(line)?.is_ascii_uppercase() {
return Some(from + line);
}
at += rest.get(at..)?.iter().position(|&c| c == b'\n')? + 1;
}
}
fn keyword(&mut self) -> Result<String, String> {
self.ws()?;
let st = self.i;
if self.peek() == Some(b'!') {
self.i += 1;
}
while let Some(c) = self.peek() {
if c.is_ascii_alphanumeric() || c == b'_' || c == b'-' {
self.i += 1;
} else {
break;
}
}
if self.i == st {
return self.fail("expected a keyword");
}
Ok(String::from_utf8_lossy(self.s.get(st..self.i).unwrap_or(&[])).to_ascii_uppercase())
}
fn number(&mut self) -> Result<Param, String> {
let st = self.i;
while let Some(c) = self.peek() {
if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.' | b'E' | b'e') {
self.i += 1;
} else {
break;
}
}
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
if !t.contains(['.', 'E', 'e'])
&& let Ok(i) = t.parse::<i64>()
{
return Ok(Param::Int(i));
}
// STEP allows `1.` and `1.E-3`.
let fixed = t.replace(".E", ".0E").replace(".e", ".0e");
let fixed = if fixed.ends_with('.') { format!("{fixed}0") } else { fixed };
match fixed.parse::<f64>() {
Ok(x) => Ok(Param::Real(x)),
Err(_) => self.fail(&format!("bad number `{t}`")),
}
}
fn string(&mut self) -> Result<String, String> {
// Opening quote consumed by the caller.
let mut raw = Vec::new();
loop {
match self.peek() {
None => return self.fail("unterminated string"),
Some(b'\'') => {
if self.s.get(self.i + 1) == Some(&b'\'') {
raw.push(b'\'');
self.i += 2;
} else {
self.i += 1;
break;
}
}
Some(b'\n') | Some(b'\r') => self.i += 1,
Some(c) => {
raw.push(c);
self.i += 1;
}
}
}
Ok(decode_string(&raw))
}
fn param(&mut self, depth: usize) -> Result<Param, String> {
if depth > MAX_DEPTH {
return self.fail("parameters nested too deeply");
}
self.ws()?;
let Some(c) = self.peek() else { return self.fail("unexpected end of file") };
match c {
b'$' => {
self.i += 1;
Ok(Param::Null)
}
b'*' => {
self.i += 1;
Ok(Param::Derived)
}
b'\'' => {
self.i += 1;
Ok(Param::Str(self.string()?))
}
b'"' => {
self.i += 1;
let st = self.i;
while self.peek().is_some_and(|c| c != b'"') {
self.i += 1;
}
let v = String::from_utf8_lossy(self.s.get(st..self.i).unwrap_or(&[])).to_string();
self.expect(b'"')?;
Ok(Param::Binary(v))
}
b'#' => {
self.i += 1;
let st = self.i;
while self.peek().is_some_and(|c| c.is_ascii_digit()) {
self.i += 1;
}
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
t.parse::<u64>().map(Param::Ref).or_else(|_| self.fail("bad entity reference"))
}
b'.' if self.s.get(self.i + 1).is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') => {
self.i += 1;
let st = self.i;
while self.peek().is_some_and(|c| c != b'.') {
self.i += 1;
}
let v = String::from_utf8_lossy(self.s.get(st..self.i).unwrap_or(&[])).trim().to_ascii_uppercase();
self.expect(b'.')?;
Ok(Param::Enum(v))
}
b'(' => {
self.i += 1;
Ok(Param::List(self.list_rest(depth + 1)?))
}
c if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.') => self.number(),
c if c.is_ascii_alphabetic() || c == b'!' => {
let name = self.keyword()?;
self.expect(b'(')?;
Ok(Param::Typed(name, self.list_rest(depth + 1)?))
}
_ => self.fail(&format!("unexpected `{}`", c as char)),
}
}
/// Parameters after an opening `(` up to and including the `)`.
fn list_rest(&mut self, depth: usize) -> Result<Vec<Param>, String> {
let mut v = Vec::new();
if self.eat(b')')? {
return Ok(v);
}
loop {
v.push(self.param(depth)?);
if self.eat(b',')? {
continue;
}
self.expect(b')')?;
return Ok(v);
}
}
fn record(&mut self) -> Result<Record, String> {
let name = self.keyword()?;
self.expect(b'(')?;
Ok(Record { name, params: self.list_rest(1)? })
}
}
/// Decode the Part 21 string escapes (`\X\hh`, `\X2\…\X0\`, `\X4\…\X0\`, `\S\c`, `\\`).
fn decode_string(raw: &[u8]) -> String {
decode_string_with_end(raw, |rest| rest.windows(4).position(|w| w == b"\\X0\\"))
}
/// The decoder with its terminator search supplied, so tests can bound search work directly.
fn decode_string_with_end(raw: &[u8], mut find_end: impl FnMut(&[u8]) -> Option<usize>) -> String {
let mut out = String::new();
let mut i = 0;
let mut no_more_run_ends = false;
let hex = |b: &[u8]| std::str::from_utf8(b).ok().and_then(|t| u32::from_str_radix(t, 16).ok());
while let Some(&c) = raw.get(i) {
if c == b'\\' {
let rest = raw.get(i..).unwrap_or(&[]);
if rest.starts_with(b"\\\\") {
out.push('\\');
i += 2;
continue;
}
if rest.starts_with(b"\\X\\")
&& let Some(v) = rest.get(3..5).and_then(hex)
{
out.push(char::from_u32(v).unwrap_or('?'));
i += 5;
continue;
}
let mut run = false;
for (tag, width) in [(&b"\\X2\\"[..], 4usize), (&b"\\X4\\"[..], 8)] {
if rest.starts_with(tag) && !no_more_run_ends {
let Some(end) = find_end(rest) else {
// No terminator exists anywhere in the remaining input. Keep the
// existing literal recovery, but never scan this tail again. A
// successful search consumes its run, so all searches together
// inspect at most the input's length.
no_more_run_ends = true;
break;
};
let body = rest.get(4..end).unwrap_or(&[]);
let units: Vec<u32> = body.chunks(width).filter_map(hex).collect();
if width == 4 {
let u16s: Vec<u16> = units.iter().map(|u| *u as u16).collect();
out.push_str(&String::from_utf16_lossy(&u16s));
} else {
out.extend(units.iter().map(|u| char::from_u32(*u).unwrap_or('?')));
}
i += end + 4;
run = true;
break;
}
}
// A run may be followed straight away by the backslash of the next escape (#2344).
if run {
continue;
}
if rest.starts_with(b"\\S\\")
&& let Some(&ch) = rest.get(3)
{
out.push(char::from_u32(ch as u32 + 128).unwrap_or('?'));
i += 4;
continue;
}
out.push('\\');
i += 1;
continue;
}
// Plain bytes: keep UTF-8 runs intact.
let st = i;
while raw.get(i).is_some_and(|c| *c != b'\\') {
i += 1;
}
out.push_str(&String::from_utf8_lossy(raw.get(st..i).unwrap_or(&[])));
}
out
}
/// Parse a Part 21 file.
/// Someone waiting for a long parse: told how far it is, and able to stop it.
pub trait Progress {
/// Stop at the next check.
fn cancelled(&self) -> bool;
/// `done` of `total` bytes read.
fn progress(&self, stage: &'static str, done: usize, total: usize);
}
/// Parse an exchange structure.
pub fn parse(text: &str) -> Result<Exchange, String> {
parse_with(text, None)
}
/// [`parse`], reporting progress to `watch` and stopping when it cancels.
pub fn parse_with(text: &str, watch: Option<&dyn Progress>) -> Result<Exchange, String> {
if text.len() > MAX_BYTES {
return err(format!("STEP file too large ({} MB, limit {} MB)", text.len() >> 20, MAX_BYTES >> 20));
}
let mut lx = Lexer { s: text.as_bytes(), i: 0, counted: Cell::new((0, 0)) };
let magic = lx.keyword()?;
if magic != "ISO-10303-21" {
return err("not a STEP file (no ISO-10303-21 header)");
}
lx.expect(b';')?;
let mut ex = Exchange::default();
loop {
lx.ws()?;
if lx.peek().is_none() {
return err("STEP file ends without END-ISO-10303-21");
}
let kw = lx.keyword()?;
match kw.as_str() {
"HEADER" => {
lx.expect(b';')?;
loop {
// The header only describes the file, and other CAD systems read past a
// record that does not parse (#11650): skip to the next line that starts
// with a keyword. If there is none, the file is not readable.
let start = lx.i;
// A header without its `ENDSEC;` must not read the DATA section as
// header records to skip.
if lx.keyword().as_deref() == Ok("DATA") {
return lx.fail("expected `ENDSEC;` before `DATA`");
}
lx.i = start;
let record = (|| {
let k = lx.keyword()?;
if k == "ENDSEC" {
lx.expect(b';')?;
return Ok(None);
}
lx.expect(b'(')?;
let params = lx.list_rest(1)?;
lx.expect(b';')?;
Ok(Some((k, params)))
})();
let (k, params) = match record {
Ok(Some(record)) => record,
Ok(None) => break,
Err(e) => {
let Some(next) = lx.next_header_line(start) else { return Err(e) };
lx.i = next;
continue;
}
};
match k.as_str() {
"FILE_SCHEMA" => {
if let Some(Param::List(v)) = params.first() {
ex.schemas = v.iter().filter_map(|p| p.as_str().map(str::to_string)).collect();
}
}
"FILE_DESCRIPTION" => {
if let Some(Param::List(v)) = params.first() {
ex.description = v.iter().filter_map(|p| p.as_str()).collect::<Vec<_>>().join(" ");
}
}
"FILE_NAME" => {
ex.preprocessor = params.get(4).and_then(Param::as_str).unwrap_or("").to_string();
ex.originating_system = params.get(5).and_then(Param::as_str).unwrap_or("").to_string();
}
_ => {}
}
}
}
"DATA" => {
// Optional `DATA('name', ('schema'));`
if lx.eat(b'(')? {
lx.list_rest(1)?;
}
lx.expect(b';')?;
loop {
lx.ws()?;
if lx.peek() != Some(b'#') {
let k = lx.keyword()?;
if k == "ENDSEC" {
lx.expect(b';')?;
break;
}
return lx.fail(&format!("unexpected `{k}` in DATA"));
}
lx.i += 1;
let st = lx.i;
while lx.peek().is_some_and(|c| c.is_ascii_digit()) {
lx.i += 1;
}
let id: u64 = match std::str::from_utf8(lx.s.get(st..lx.i).unwrap_or(&[])).ok().and_then(|t| t.parse().ok()) {
Some(id) => id,
None => return lx.fail("bad entity id"),
};
lx.expect(b'=')?;
let records = if lx.eat(b'(')? {
let mut rs = Vec::new();
while !lx.eat(b')')? {
if rs.len() > 64 {
return lx.fail("complex entity with too many parts");
}
rs.push(lx.record()?);
}
rs
} else {
vec![lx.record()?]
};
lx.expect(b';')?;
if ex.entities.len() % 4096 == 0
&& let Some(w) = watch
{
if w.cancelled() {
return err("STEP import cancelled");
}
w.progress("reading", lx.i, lx.s.len());
}
if ex.entities.len() >= MAX_ENTITIES {
return err(format!("STEP file has more than {MAX_ENTITIES} entities"));
}
// A duplicate id is invalid; some writers emit them for geometry nobody
// reads. Keep the first definition and report it.
match ex.entities.entry(id) {
std::collections::hash_map::Entry::Occupied(_) => ex.duplicates.push(id),
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(Entity { records });
}
}
}
}
"END-ISO-10303-21" => {
lx.expect(b';')?;
return Ok(ex);
}
// ANCHOR / REFERENCE / SIGNATURE sections (Part 21 edition 3): skip to ENDSEC.
"ANCHOR" | "REFERENCE" | "SIGNATURE" => {
let rest = lx.s.get(lx.i..).unwrap_or(&[]);
match rest.windows(7).position(|w| w == b"ENDSEC;") {
Some(p) => lx.i += p + 7,
None => return lx.fail("unterminated section"),
}
}
_ => return lx.fail(&format!("unexpected `{kw}`")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unterminated_unicode_escape_search_is_linear() {
// A separator keeps the closing backslash from pairing with the next opening
// backslash as an escaped `\\`. Without it, only the first tag is searched.
for (tag, literal) in [(b"\\X2\\ ", "\\X2\\ "), (b"\\X4\\ ", "\\X4\\ ")] {
let raw = tag.repeat(100_000);
let mut comparisons = 0;
let decoded = decode_string_with_end(&raw, |rest| {
rest.windows(4).position(|w| {
comparisons += 1;
assert!(comparisons <= raw.len(), "the decoder searched the same tail repeatedly");
w == b"\\X0\\"
})
});
assert_eq!(decoded, literal.repeat(100_000));
}
}
#[test]
fn terminated_runs_and_literal_recovery_keep_their_text() {
let raw = b"\\X2\\00E9\\X0\\".repeat(1_000);
let mut comparisons = 0;
let decoded = decode_string_with_end(&raw, |rest| {
rest.windows(4).position(|w| {
comparisons += 1;
assert!(comparisons <= raw.len());
w == b"\\X0\\"
})
});
assert_eq!(decoded, "\u{e9}".repeat(1_000));
assert_eq!(decode_string(b"\\X2\\oops \\X\\41 \\S\\a"), "\\X2\\oops A \u{e1}");
assert_eq!(decode_string(b"\\X2\\\\X2\\"), "\\X2\\X2\\");
}
#[test]
fn parses_simple_and_complex() {
let t = "ISO-10303-21;\nHEADER;FILE_DESCRIPTION((''),'2;1');\nFILE_NAME('a','',(''),(''),'','sys','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n#1=CARTESIAN_POINT('p',(0.,1.5E1,-2));\n/* c */ #2=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\n#3=X('it''s','\\X2\\00E9\\X0\\',$,#1,LENGTH_MEASURE(1.E-3),\"0F\");\nENDSEC;\nEND-ISO-10303-21;\n";
let ex = parse(t).unwrap();
assert_eq!(ex.schemas, vec!["AUTOMOTIVE_DESIGN"]);
assert_eq!(ex.originating_system, "sys");
let p = ex.get(1).unwrap();
assert_eq!(p.name(), "CARTESIAN_POINT");
let xyz: Vec<f64> = p.params()[1].as_list().unwrap().iter().filter_map(Param::as_f64).collect();
assert_eq!(xyz, vec![0.0, 15.0, -2.0]);
let u = ex.get(2).unwrap();
assert!(u.is_complex() && u.has("SI_UNIT"));
assert_eq!(u.record("SI_UNIT").unwrap()[0].as_enum(), Some("MILLI"));
let x = ex.get(3).unwrap().params();
assert_eq!(x[0].as_str(), Some("it's"));
assert_eq!(x[1].as_str(), Some("\u{e9}"));
assert_eq!(x[2], Param::Null);
assert_eq!(x[3].as_ref_id(), Some(1));
assert_eq!(x[4].as_f64(), Some(1e-3));
}
/// #11650: the header is metadata, so a malformed record in it must not stop the import.
#[test]
fn a_malformed_header_record_is_skipped() {
let t = "ISO-10303-21;
HEADER;
FILE_DESCRIPTION((''), '2;1');1');
FILE_NAME('','',(''),(''),'','sys','');
FILE_SCHEMA (('AUTOMOTIVE_DESIGN'));
ENDSEC;
DATA;
#1=CARTESIAN_POINT('p',(0.,1.,2.));
ENDSEC;
END-ISO-10303-21;
";
let ex = parse(t).unwrap();
// The records after the broken one are still read.
assert_eq!(ex.originating_system, "sys");
assert_eq!(ex.schemas, vec!["AUTOMOTIVE_DESIGN"]);
assert_eq!(ex.get(1).unwrap().name(), "CARTESIAN_POINT");
// A header with nothing after the junk to resume at is still an error.
assert!(
parse(
"ISO-10303-21;
HEADER;
FILE_NAME('a');junk').;ENDSEC;DATA;ENDSEC;END-ISO-10303-21;"
)
.is_err()
);
}
/// #11650: skipping bad header records stops at DATA when the header's `ENDSEC;` is
/// missing, so the entities are not skipped as header records and the file is an error.
#[test]
fn a_header_without_endsec_does_not_swallow_the_data_section() {
for data in ["DATA;", "DATA('d', ('AUTOMOTIVE_DESIGN'));"] {
let t = format!("ISO-10303-21;\nHEADER;\nBAD;\n{data}\n#1=A();\nENDSEC;\nEND-ISO-10303-21;\n");
let e = parse(&t).unwrap_err();
assert!(e.contains("line 4"), "{e}");
}
}
/// #11650: many bad header records in a row are skipped in time that grows with the file,
/// and the error after them still names the right line.
#[test]
fn many_bad_header_records_are_skipped_in_one_pass() {
let n = 100_000;
let bad = "BAD;\n".repeat(n);
let ok = format!("ISO-10303-21;\nHEADER;\n{bad}ENDSEC;\nDATA;\n#1=A();\nENDSEC;\nEND-ISO-10303-21;\n");
assert_eq!(parse(&ok).unwrap().get(1).unwrap().name(), "A");
let broken = format!("ISO-10303-21;\nHEADER;\n{bad}DATA;\n#1=A();\nENDSEC;\nEND-ISO-10303-21;\n");
let e = parse(&broken).unwrap_err();
assert!(e.contains(&format!("line {}", n + 3)), "{e}");
}
#[test]
fn hostile_text_is_an_error() {
for t in [
"",
"garbage",
"ISO-10303-21;",
"ISO-10303-21;DATA;#1=A(",
"ISO-10303-21;DATA;#1=A('x",
"ISO-10303-21;DATA;#1=A(/*",
"ISO-10303-21;DATA;#=A();ENDSEC;END-ISO-10303-21;",
"ISO-10303-21;DATA;#99999999999999999999999=A();ENDSEC;END-ISO-10303-21;",
"ISO-10303-21;DATA;#1=A(1e999999.);ENDSEC;END-ISO-10303-21;",
] {
let _ = parse(t);
}
let deep = format!("ISO-10303-21;DATA;#1=A({}{});ENDSEC;END-ISO-10303-21;", "(".repeat(10_000), ")".repeat(10_000));
assert!(parse(&deep).is_err());
let dup = parse("ISO-10303-21;DATA;#1=A(1);#1=B();ENDSEC;END-ISO-10303-21;").unwrap();
assert_eq!(dup.duplicates, vec![1]);
assert_eq!(dup.get(1).unwrap().name(), "A");
assert_eq!(decode_string(b"\\X\\E9"), "\u{e9}");
assert_eq!(decode_string(b"a\\X2\\00E900E8\\X0\\b"), "a\u{e9}\u{e8}b");
assert_eq!(decode_string(b"\\"), "\\");
// One run per character, as `step_out` writes them (#2344).
assert_eq!(decode_string(b"\\X2\\5706\\X0\\\\X2\\89D2\\X0\\20"), "\u{5706}\u{89d2}20");
assert_eq!(decode_string(b"\\X4\\0001F600\\X0\\\\X2\\00E9\\X0\\\\S\\a"), "\u{1f600}\u{e9}\u{e1}");
}
}

Some files were not shown because too many files have changed in this diff Show More