* STEP import: mesh band faces that wrap around a closed surface correctly
Fixes two related bugs that caused SolidCraft to fail to mesh a face wrapping
all the way around a closed, non-periodic surface (a revolved ridge band, or a
tube-shaped B-spline surface): a missing face, a folded/self-overlapping
triangulation, or a hole in the rendered body.
1. Ring edges that run slightly off their surface. STEP files write a surface
and the curves bounding it to the file's own declared uncertainty, so a
ring edge can legitimately sit a few micrometres off the surface it
bounds. The code finding where a ring edge crosses the planned split line
compared against the edge's raw (off-surface) point using a hardcoded
tolerance far stricter than the file's own precision, rejecting a valid
cut. Now matches against the point on the surface directly below the ring
crossing, respecting the file's declared tolerance.
2. Closed B-spline surfaces, their slits and their slivers. truck only treats
the rotation of a revolved surface as periodic; a face on a B-spline
surface whose control net closes on itself (a tube) wasn't recognized as
wrapping around at all. This produced a folded face, a hole (a boundary
loop landing a full period away from where it should cut), and one
near-zero-width sliver face costing the entire body its tessellation
plan. Adds periodicity detection for closed B-spline surfaces, period-
aware hole-loop alignment, dropping of zero-width slit boundaries, and
isolating a too-thin-to-triangulate face so it no longer takes the rest
of the body down with it. The GPU shader's periodic wrap mirrors the CPU
math so the two don't drift apart.
Regression tests: band_faces_split_when_ring_edges_run_off_their_surface,
band_face_with_a_zero_width_slit_is_still_meshed,
a_sliver_face_too_thin_to_triangulate_does_not_cost_the_body_its_plan.
Ablation-tested: disabling either mechanism in (2) reproduces its target
test's original failure message while leaving the other test unaffected.
* STEP import: mesh the band's split faces, not just split them
jyviko asked whether a band wall missing from the interactive app's render
was a shading issue or a genuinely missing face. It was missing: the ring-
edge split fix made the band's topology correct, but plan_face still
produced zero triangles for the two new sub-faces.
Two causes, both in plan_face (crates/kernel/src/tess/mod.rs):
1. The tolerance for how far an imported boundary may lie off its surface
was tied to the requested mesh's chord tolerance instead of to the
body's own size. A finer display tolerance could then reject a boundary
a coarser one had just accepted, so the face would drop out exactly when
zooming in on it.
2. Near a sharp corner of this flat-panelled band, several boundary points
that are legitimately a few micrometres apart (within the STEP file's
own declared precision) all projected onto the same surface point. The
boundary loop then repeated a point in (u, v), which a triangulator
can't follow, so the face's plan failed outright. One face without a
plan costs the whole body its plan, and the fallback mesher can't
handle a closed B-spline surface either, leaving both halves of the
split band with no triangles at all: present in the topology, invisible
in every renderer, and absent from the body's volume.
Fix: scale the off-surface tolerance to the body's own extent (new
Rules::extent), and collapse a run of boundary nodes that land on the same
surface point to one (drop_collapsed) before triangulating.
Regression test additions to band_faces_split_when_ring_edges_run_off_their_surface:
the body's volume now matches the analytic pentagon prism (was a third of
it - only the top cap - before this fix); display_plan leaves no face
without triangles from coarse to fine tolerance; and the actual tolerance
and facet angle the viewport draws with gives the split band its full
lateral area on both halves.
Confirmed on review_band_big.step, the fixture behind the review screenshots:
volume's relative error against the analytic pentagon prism drops from
1.4e-4 to 4.1e-6, and the band wall now occludes the floor grid behind it
in a snapshot the same way the top and bottom caps always did.
Full kernel suite: 739 tests, 0 failed.
* STEP import: hold an imported boundary to the file's own tolerance, not the part's size
jyviko: the off-surface acceptance added in the last commit
(rules.extent * OFF_SURFACE) scaled with the part, so a 2 m part
silently accepted a boundary 2 mm off its surface while a 20 mm copy
of the same part accepted only 0.02 mm. A STEP file's own declared
uncertainty does not grow with the part, so the bound shouldn't either.
Body gains boundary_tol: Option<f64>, Some(file's declared tolerance)
for a body read from STEP (crate::step_in::Units::tol, already used
by the ring-split fix in split.rs), None for anything this kernel
built. It's set before the import's own unmeshed-face check runs, and
carried over by the two face-split operations that re-cut an imported
surface (splitface.rs, outline.rs), since the file's uncertainty still
describes those boundaries afterward.
plan_face's accept now reads: the file's own tolerance when the body
declares one, or the old chord-tolerance/extent bound otherwise. The
old bound's tol * 10.0 term had to go too, not just the extent term:
at a 2 m part's typical display tolerance that term alone is ~20 mm,
nearly ten times the extent bound jyviko objected to, so keeping it
would have left the exact 2 m/2 mm example still silently accepted.
Two new tests in step_in/tests.rs:
- band_faces_split_when_a_scaled_copy_runs_off_its_surface: the same
band fixture at a realistic 2 m scale with the same absolute slop
and declared uncertainty as the small one, meshes the same way at
every chord tolerance from coarse to fine.
- off_surface_budget_follows_the_file_not_the_part_size: the actual
regression. The same off-surface error is accepted or refused the
same way whether the part is 20 mm or 2 m; a 2 mm error against a
1 µm declared uncertainty is refused at both sizes now (accepted at
2 m under the old bound, refused at 20 mm - that asymmetry was the
bug). Checked at both a coarse and a fine display tolerance.
Known gap, not fixed here: a STEP-imported body saved into a
.solidcraft design and reopened loses boundary_tol (native.rs's
decoder has no field to read it from) and falls back to the old
extent-based bound on reload. Today's design format has no concept of
this value at all; persisting it is a real format change, out of
scope for this fix.
Full kernel suite: 776 tests, 0 failed. clippy -D warnings clean.
* Display plans for STEP threads and pointed ends (#3847)
Three faces cost a threaded or pointed STEP part its whole display plan, so it
was drawn from the reference mesh: cracked, faceted, and never refined up close.
- A loop through a pole (a cone's apex on the boundary) seemed to wind around
the surface: the jump at the apex, unwrapped to the nearest copy, was
arbitrary. The loop is turned to end where it leaves the pole, so the step
that closes it is an ordinary one (`seam::close_through_pole`).
- Projection onto a spline with hundreds of spans (a helical flank) restarted
from a 16 x 16 grid and settled on another turn; it now starts from two
points per span, capped.
- Sag refinement no longer inserts a point exactly on a boundary side, which
split the side and failed the face.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* tess: a refused face's edges are divided finer and it is planned again (#2814)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
* tess: sliver faces plan empty; a face no method plans is filled flat from its nodes (#2814)
A face whose boundary runs out and back along itself (two edges between the
same vertices, a rounding apart, as STEP files write slivers) encloses nothing
and is planned with no triangles. A face still refused after its edges are
divided finer (a cusp, a sliver whose sides sag across each other, a small cap
at a pole) is filled flat from its shared boundary nodes, so the rest of the
body keeps its display plan instead of falling back to the reference mesh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
* display mesh tests: a rim the chord tolerance leaves a line is divided again (#2814)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
* portable B-rep: up to 8192 control points along a spline direction (#2814)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
* solidcraft-cli: --debug logs what the engine reports to stderr
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
* STEP import: a spline written over a tiny parameter range is read over one (#2814)
Some writers parameterize splines over a few millionths (a humanoid robot's
edges span 5.4e-6). The kernel tells parameters apart only to 1e-6, so over
such a range it evaluated the curve's end wrongly, and trimming an edge to its
vertices dropped a control point: the bodies could not be stored exactly and
the whole model cache was left out of the design. The knots are now stretched
to span one; the curve is the same and edges are trimmed by their vertices, so
nothing read from the file refers to the old parameters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
* model cache: a body keeps its file's declared accuracy (#2814)
A body restored from the cache dropped the accuracy the file it was read from
declares, so the planner held its boundaries to the kernel's own bound and a
reopened design drew differently from the import. It is stored with the body's
runtime values, and only when there is one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
* tess: a boundary within a hundredth of its body lies on its surface, at every quality (#2814)
#1721 held a body read from a file to the accuracy the file declares. A writer
declares what it aims for, not a bound its edges keep: a 251-body robot
assembly declaring 0.01 µm has boundaries 10 to 30 µm off their surfaces, and
95 of its bodies lost their display plans. The bound is a hundredth of the
body again, what Normal quality always allowed, and now the same at every
quality, so zooming in still never drops a face (#1721's point). A file
declaring more raises it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
* STEP import: keep faces whose edge vertices sit slightly off their curves
Some exporters write an edge's vertex beside the end of its curve, further off
than the accuracy the file declares (a surface export declares
0.01 mm and puts a vertex 0.19 mm off two spline ends). The reader refused
such edges and left the face, and with single-face bodies the body, out.
A vertex may now sit up to the file's tolerance or 1/1000 of the model's size
(the diagonal around its VERTEX_POINTs), whichever is larger, never more than
the existing 1 mm ceiling. Spline and open-conic edges have the end control
point moved onto the vertex, so every such edge meets its vertices exactly.
The file's declared accuracy still governs closed-curve, seam and circle
checks.
* tess, io: review clean-ups (#2814)
- A sliver face is one whose loops are all slit: drop_slits finds it in the
retry after a failed plan, so the pre-check on every face (folded) is gone.
- The flat fill uses cdt::triangulate (a lenient fill that leaves out sides
crossing one already kept) and its flood-fill inside test, and the plane
from geom::Plane.
- Refinement only builds its indexes when a face was refused, takes the
faces to plan again from edge_faces, and re-samples edges on the pool;
Rules::finer for the finer rules.
- One PackageWriter::fits for the model cache, the preview and the thumbnail.
- The band split's ring tolerance lives in split.rs; comments say what the
off-surface bound is now.
- The CLI logger prints Level::as_str().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
---------
Co-authored-by: David Lackovic <lackovicdavid1234@gmail.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Ray <raykholo@users.noreply.github.com>
* Snapshot: draw a see-through appearance see-through, and give the CLI --body, --tool and --section
A headless picture dropped a body's opacity: the scene carried only a colour, so a design with
a translucent appearance was drawn opaque by `solidcraft-cli snapshot`, by MCP screenshots and
in a design's thumbnail, although the window has always drawn it as glass. There was then no
way to look at the body a cut would remove inside the material it meets: only a volume to
measure and reason about, which hides exactly the errors a picture makes obvious (a tool that
stops short, or lands in the wrong place).
- `SceneMesh` carries the body's opacity; `view::build_scene` takes it from the design
(`model_body_opacity`), and a selected body stays solid so the selection colour reads.
- The GPU capture routes a see-through body into the scene's glass at its own alpha, as the
viewport does; the CPU rasterizer draws the solid bodies first (they own the depth buffer),
then the see-through ones blended over them, far sides before near sides and without writing
depth, so what lies inside shows and a line between the two is not hidden.
- `snapshot --body NAME:opacity=0.3,color=#rrggbb`, `--tool NAME` (that body solid in the tool
colour, the rest see-through) and `--section z=27` run the commands the app itself runs
(`appearance.assign`, `inspect.section`): the options are shortcuts, not a second renderer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Tests: measure the tool colour as it is drawn through a see-through target
The tool-colour filter was derived on paper and demanded a red more than twice either other
channel, which the target in front of it tints away (measured: (200, 108, 85) through opacity
0.3); and the colour check painted the tool, which a solid target hides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fmt
* Snapshot options: strip the colour's # rather than slicing it; README
Slicing `value[1..]` on an input-derived string is what the house rules ask us not to write,
even where a length check has already made it safe. The error a failed assignment reports now
names the body rather than claiming it came from --body, since --tool reaches it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* --tool: assign the bodies around the tool in groups, not one command at a time
Each appearance.assign is an undo step with a document snapshot behind it, so one per body
made --tool cost grow with the size of the design for no reason: the bodies around the tool
all get the same opacity, and the ones that share a colour can be named in one call. A design
whose bodies all wear the default grey now costs two commands whatever its size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Rasterizer: say where its glass and the GPU's differ
Splitting each see-through body by facing orders its own two layers; it does not sort across
bodies, which the GPU does. Worth stating rather than leaving a reader to find it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
* claim: #2823
* fix(view): lines thinner than a pixel no longer vanish without multisampling (#2823)
A line quad narrower than a pixel covers no pixel centre when it falls between
two rows, so the grid's 0.7 px lines came and went as the view zoomed where the
viewport draws without 4x MSAA (some browsers and devices). Such lines are now
drawn a pixel wide, faded by the width they lack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Centralise tool modifiers with classic defaults and preview-safe Ctrl.
Route sketch/selection chords through a shared resolver. Keep Alt snap
suppress and dual toggle as defaults; optional Shift add-only and Ctrl
snap mapping. Skip preview rollback while a manipulator drag is active.
* Address #1616 review: classic snap suppress, single viewport owner, docs.
Default suppress to Ctrl/Cmd or Alt; drop navigation set_suppressed; remove unused label_key; add preview rollback gate test; document sketch modifiers.
* fix(#1616): correct Modifiers primary in tests; simplify rollback fixture.
* Treat Ctrl as primary off macOS, and rustfmt the modifier files.
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)
Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)
FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Packs the scene's planned bodies into one GPU upload and dispatch, and evaluates intersection edges by seeded Newton refinement from a fitted B-spline through exactly solved leader points, falling back to the previous evaluation near tangency. Intersection nodes carry their faces' parameters. This changes speed, not what the app draws.
Part 4 of 4 of #303.
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>