Compare commits

..
Author SHA1 Message Date
SoftFever 693d6cbf6e Merge branch 'main' into image-gizmo 2026-10-13 19:51:55 +08:00
ExPikaPaka f546551b3f Use a model's own UV channel for texture displacement, and keep its colours through a bake (#16394) 2026-10-13 19:51:07 +08:00
Kris Austin ab42ddeef2 ci: replace deploy-nightly action with a local composite action (#16419)
WebFreak001/deploy-nightly still targets Node.js 20, so every main build
logged a deprecation warning per deploy step. The action has had no
release since 2024 and its Node 24 PR is unmerged.

The local action in .github/actions/deploy-nightly uploads the new file
under a temporary name and renames it once the upload finishes, so a
failed upload keeps the existing nightly asset. It also fails on a missing
file before deleting anything, retries transient API errors, times out
stalled transfers, handles concurrent runs, and finds the release by its
nightly-builds tag. The macOS validator DMG deploy now runs only when that
DMG was built.
2026-10-13 06:19:07 -05:00
ExPikaPakaandSoftFever 39c77325ce Carry existing colour across a bake instead of reindexing it (#16392)
* Carry existing colour across a bake instead of reindexing it

Baking, painting colour, then baking again left colour debris on surfaces
nothing had painted and destroyed what the earlier bake had laid down.

TextureDisplacementBakeJob::finalize() deserialized the volume's pre-bake
mmu_segmentation_facets into a TriangleSelector built on the post-bake mesh,
on the assumption that the bake is topology-preserving. That holds for the
classic path, which moves vertices and leaves the topology alone, but not for
the one-run pipeline, which rebuilds the mesh. TriangleSelector::deserialize()
only checks that the stored triangle ids are in range, and a bake almost
always grows the triangle count, so every stale id passed that check and
landed on an unrelated face.

Measured on a 26 mm slab baked twice at 0.19 mm with a 750 k budget: 7580
triangles ended up carrying paint neither bake produced, as far as 31 mm from
any colour that was actually laid down, while the first pass's colour was lost
where the second bake left the surface alone.

TriangleSelector::remap_painting() already transfers paint between meshes by
position, and ModelVolume::restore_painting() uses it for the same reason, so
the bake job now uses it too. It runs in process(), on the worker thread: it
builds an AABB tree over both meshes and took around a second on the case
above, which would have frozen the UI in finalize(). The bake's own colour is
still applied last, so it wins where it painted and the rest is kept, which is
what makes successive bakes accumulate rather than overwrite. If the volume is
painted while the bake is running, a timestamp check falls back to the live
mask so that stroke is not dropped.

The out_triangle comment in TextureColorRequest claimed the bake is
topology-preserving, which is what justified the original indexing; it now
says which mesh the colours belong to.

The regression test bakes twice, checks both results are closed, and checks
the first bake's colour survives the second at the positions where it was
laid down and nowhere else.

* Carry the paint across with transfer_facet_paint

The branch used TriangleSelector::remap_painting(), which pairs a source
paint triangle with a target triangle overlapping it to within
TriangleCursor::tolerance. That is right after a remesh, which leaves the
surface where it was, and wrong after a bake, which has moved it by the
relief depth - most of the paint finds no partner. transfer_facet_paint()
asks the other question: every triangle of the new mesh takes the state
painted at its foot point on the old one, which is exactly where it came
from however deep the relief.

It also does all four paint channels rather than colour alone, and it runs
where the volume is read, so the snapshot and the timestamp that guarded
against the user painting while the bake ran are no longer needed: the
annotation is read at commit time and is by definition current.

The function and its call site are byte for byte what the model-UV branch
carries, so the two merge in any order and in either order produce one
mechanism rather than two fighting over mmu_segmentation_facets. The
regression test is unchanged except for the call it makes.

---------

Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
2026-10-13 19:05:05 +08:00
ExPikaPaka 053019261f Merge remote-tracking branch 'origin/image-gizmo' into image-gizmo
# Conflicts:
#	src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp
2026-10-13 08:47:58 +02:00
ExPikaPaka b0d586837b Merge upstream main into image-gizmo
Three conflicts, all in the texture displacement gizmo, all resolved by
keeping both sides:

- Two independent additions landed next to apply_standard_mode_presets():
  upstream's store_panel_state() and this branch's image-mode declarations.

- m_color_tex_source: upstream changed it from a raw const void* to a
  weak_ptr to the image bytes, so a freed and reallocated image can no longer
  pass for the old one. That is the better key and it is kept; the colour
  correction key this branch added sits beside it, and the cache test now
  checks the image, the smoothing and the correction.
2026-10-13 08:43:14 +02:00
ExPikaPaka 8ac8a75056 Include the headers GLGizmoImage.cpp uses directly
Adds the includes misc-include-cleaner named: libslic3r/Point.hpp for
Vec2d, Vec3f and Transform3d, GLGizmoTextureDisplacement.hpp for the
base class, IconManager.hpp for the icon lookup, and cmath, cstddef
and cstdlib for round, size_t and abs.
2026-10-12 15:31:21 +02:00
ExPikaPaka 60248cadd3 Move the Image tool's declarations off the line main now uses
store_panel_state() landed in the same place, from the .3mf persistence
branch. Same declarations, a few lines down, after bake_standard().
2026-10-12 15:14:22 +02:00
ExPikaPaka c07a082050 Add the pipette icon on a line of its own
Three other open texture-displacement branches add an icon to this list too.
Reflowing the lines they also touch is what turns four one-word additions
into four conflicts; a new line four lines away from the next one merges in
any order. Order in the list is irrelevant - the map keys by file name.
2026-10-12 14:03:43 +02:00
ExPikaPaka c92721cabf fix: Negative belongs with the colour corrections, not under Background
It sat below the Background section purely because of source order, which
read as if it were part of it. Reset moves out of both sections and becomes
Reset all, since it clears the corrections and the background key alike and
a reset living inside one of them would look like it only reached that far.
2026-10-12 13:30:31 +02:00
ExPikaPaka 47a4e7447f feat: a real dropper that reads the model, foldable sections, a scrolling panel
- The dropper has its own pipette icon instead of borrowing the placement
  handle's, drawn in the house style: 16 px canvas, 0.4 stroke in #949494,
  teal on the tip, which is the part that carries the meaning.

- It picks off the model as well as off the preview. A click on the 3D view
  while it is armed reads the picture's own colour at that point, through
  the same projection the bake uses, not the pixel on screen: the screen
  pixel carries the lighting and the filament the colour was already
  quantized to, so keying on it would remove the wrong thing. The patch
  frame comes from extract_painted_patch() plus
  texture_displacement_patch_frame(), so a cylindrical or spherical layer
  reads at the same place the bake will.

- Placement, Color and Background fold away, with the triangle-and-text the
  displacement panel's "More settings" link uses rather than ImGui's own
  filled bar, which is louder than anything else in this window. Folded,
  Background still says whether it is removing anything. Open by default and
  remembered for the session only: this is panel state, not something a
  project should carry.

- The body scrolls and the footer is pinned under it, the shape the
  displacement panel already uses. With the panel this tall, Apply and Close
  were the first things to go off the bottom of a short canvas.
2026-10-12 13:21:41 +02:00
ExPikaPaka 4286cb30ba feat: a colour count instead of Two colours, and a pickable background
Two colours was the N=2 case of a more useful control, so it is replaced by
a Colors slider: 0 for off, then 2 to 12. The picture is reduced to that many
colours before the filaments are matched to it, so it prints in at most that
many.

Reduced by k-means in RGB rather than by posterizing the range, because the
useful question is which colours the picture is actually made of. A tartan
asks for its own handful of greens and reds; an even split of the range would
hand it eight shades of mud. The clustering runs over a coarse 16-per-channel
histogram, 4096 weighted points instead of a million pixels, which is what
makes it cheap enough to sit on a slider, and it is seeded the k-means++ way
so a picture with one dominant colour does not get every centre inside it.

This is not the displacement tool's "Mixed colors", which caps how many mixed
filament slots a bake may create. Nothing like it existed.

Background removal can now be told which colour to remove. The four corners
stay the default, because a background that does not reach all four of them
is not a background and that makes the common case one click, but the panel
shows the colour as a swatch that is also a picker, and a dropper beside it
takes the colour from the preview. The dropper reads the decoded image rather
than the screen: the preview is scaled, and it already has the corrections
applied, so a screen read would key the corrected colour against itself.
"Corners" puts it back.

Measured on Tartan, a cube face, four filaments:

  run                    out tris  coloured  filaments  filament shares
  image, no correction      22534     14544          4  1:91% 3:8% 4:1%
  image, 2 colors           18232     10268          2  1:66% 3:34%
  image, 3 colors           24458     16459          3  1:78% 3:18% 4:5%
  image, 5 colors           23972     15978          4  1:90% 3:9% 4:1%

Asking for 2 gives 2 and asking for 3 gives 3. Asking for 5 gives 4, which is
the contract working the right way round: only four filaments are loaded, and
"at most" is the guarantee. No run moves the surface.
2026-10-12 13:01:06 +02:00
ExPikaPaka 3ee9c02acf feat: sharpen, two colours, and remove background in the Image tool
Three more corrections, chosen because the output is a handful of flat
filament colours rather than a continuous image: what helps is whatever
decides which filament an area lands in and where the boundary between two
of them falls.

- Sharpen. An unsharp mask over the colour, reusing the sliding-window blur
  the Smoothing slider already uses, run per channel. Worth more here than
  in a photo editor: a soft edge between two colours quantizes into a ragged
  band of both, and sharpening first makes it land in one place.

- Two colours. Thresholds on luma so a logo or line art prints in exactly
  two filaments instead of speckling across several, with the split point on
  a slider.

- Remove background. The one that is not an RGB transform. DecodedHeightTexture
  gains an alpha plane, filled from the source PNG's fourth channel when it
  has one, and the key additionally clears whatever colour the picture's four
  corners share. A transparent texel is not black: sample_layer_color()'s
  covered() now reports it the same way it reports being outside an untiled
  placement, so the triangle keeps the object's own colour and the bake needs
  no new concept. The preview follows: upload_color_texture() box-filters
  coverage into the alpha channel it used to fill with 255, and the shader
  leaves the surface alone where it is below half.

The corners rather than an eyedropper: a background that does not reach all
four of them is not a background, and a picker would be a second interaction
for the case this exists to make one click. The median of the four is taken
so one stray corner cannot decide it.

Measured on Tartan, a cube face, four filaments:

  run                    out tris  coloured  max offset mm  filament shares
  image, no correction      22534     14544       0.000005  1:91% 3:8% 4:1%
  image, sharpen 1.0        24780     16783       0.000004  1:84% 3:12% 4:3%
  image, two colors         14798      6837       0.000005  2:22% 4:78%
  image, bg removed         14120      2620       0.000005  1:29% 3:16% 4:55%

Background removal cuts the painted area from 14544 triangles to 2620, which
is the whole point of it; the threshold collapses to two filaments; sharpen
shifts the shares and adds boundary triangles. None of them moves the
surface. The probe's default scene is now Tartan rather than Colour Bricks,
which has too little luma range for the threshold to show anything.
2026-10-12 12:37:57 +02:00
ExPikaPaka 533aaaed28 feat: add Hue to the Image tool's colour correction
Turns every colour of the picture around the colour wheel without changing
how light it is. This matters more here than in a photo editor: the filament
set is fixed and arbitrary, so hue is what lets the picture meet the palette
rather than the other way round. With orange loaded but no red, a small turn
puts the reds onto the orange instead of onto whatever happens to be nearest.

Implemented as Rodrigues' rotation of the colour about the grey axis, then
the Rec. 709 luma is restored exactly: the rotation preserves R+G+B, not the
weighted luma, and without the correction turning the wheel would also change
which areas read as light and dark.

Measured, same scene and harness as the other corrections:

  run                      out tris   coloured  max offset mm  filament shares
  image, no correction        12066       4052       0.000005  1:71% 2:25% 3:4%
  image, hue +60               8208        268       0.000005  2:100%
  image, hue -120              8208        268       0.000005  3:100%

The reddish brick moves onto a different filament in each direction and the
surface does not move.
2026-10-12 12:22:30 +02:00
ExPikaPaka 4dc87e876e feat: show a drop zone while a file is dragged over the view
Dropping a picture already worked, but a drag had no feedback at all: the
only way to find out whether letting go would do anything was to let go.

While a drag is over the 3D view and the Image tool is open, the canvas now
dims and shows a dashed teal border with "Drop a picture to put it on the
model" in the middle. It goes away on leave and on drop.

Three things worth knowing about how it is wired:

- wxWidgets cannot hand us the file names before the drop on GTK, so the
  zone says nothing about what is actually being dragged. It is worded as an
  invitation rather than a promise, and anything that is not a picture still
  falls through to the model loader exactly as before.

- A drag is not a mouse move as far as the canvas is concerned, so nothing
  would repaint and the zone would appear only once something else happened
  to trigger a frame. set_file_drag_over() marks the canvas dirty and asks
  for an extra frame.

- OnEnter and OnLeave are declared virtual without override, matching the
  class's existing OnDropFiles. Marking only the new ones trips
  -Winconsistent-missing-override on that pre-existing line, which is an
  error here.

The zone is drawn on the foreground draw list from inside the panel's ImGui
frame, so it covers the panel as well as the view.
2026-10-12 12:05:22 +02:00
ExPikaPaka 8bb2a1eec5 feat: match the icon weight, drop a picture onto the view, handle on by default
- The toolbar icon's stroke is 0.4 at its 16 px canvas, which renders at
  1.00 px on the 40 px sprite. Measured rather than judged: every icon on the
  gizmo bar was rasterized at 10x and the median thin run length taken, and
  1.00 px is what thirteen of the seventeen use. The previous 0.75 came out
  at 1.90, the heaviest on the bar.

- Dropping a picture on the 3D view loads it, when the Image tool is the one
  open. It is imported into the user's library on the way, as the panel's
  Import tile does, so it is there next time. With the tool closed a dropped
  PNG goes on doing whatever it did before. This mirrors the SVG drop that
  already fed the text tool, and sits next to it.

- The placement handle comes up by itself the first moment there is something
  to anchor it to, so a picture can be nudged straight after the click that
  placed it. Armed once, so turning it off stays off.

Two things had to change for that last one to be usable rather than annoying:

- A click that misses the handle now falls through to placing the picture.
  on_mouse() used to return false there and swallow it, which is right for
  the displacement tool, where the user turned the mode on deliberately, and
  wrong here, where it is on from the start and would have disabled the
  tool's one gesture.

- update_model_object() re-anchors the handle after a stroke that moved the
  paint. Previously the anchor was only refreshed on a layer switch or
  reorder, so clicking a different face left the handle behind on the old
  one.
2026-10-12 11:48:33 +02:00
ExPikaPaka 518f961640 feat: empty start, user images only, untiled preview, dockable Image panel
Five fixes to the Image tool, from using it:

- The layer now starts with no picture on it, so the panel opens on its empty
  frame with a plus in it. It used to seed the first shipped colour texture,
  which put somebody else's picture on screen before the user had chosen one.
  The frame is the only state the panel has until a picture is loaded, and
  clicking it is what creates the layer.

- Its picker offers only the user's own images. The shipped library is
  grayscale height maps, which is what the displacement tool wants and the
  opposite of what an image bake can use: it prints colour, and a height map
  has none. The Built-in section is left out rather than shown full of things
  that would all warn when picked, and the import hints now talk about
  pictures instead of height maps.

- The preview stopped tiling a layer that is not tiled. The shader had no
  tile_enabled uniform and wrapped the image unconditionally, so an untiled
  picture previewed as a repeating pattern the bake would never produce.
  in_tile() now draws the same line DecodedHeightTexture::sample() does, and
  gates the colour and all three relief paths on it. Both the 140 and the 110
  variant; glslangValidator passes on each.

- The panel docks against the right edge of the canvas, where the
  displacement panel is, with the same undock toggle in its title row, so the
  two tools do not put their controls in different corners.

- The toolbar icon's frame is square and its stroke thinner. At 16 px scaled
  to the toolbar a 1 px stroke came out at 2.5 px, heavier than every
  neighbour; 0.75 px matches them.
2026-10-12 11:31:33 +02:00
ExPikaPaka 680a597567 feat: show the picture whole in the Image panel, add tile, move and detail
Five changes to the Image tool, all in its own window bar the presets:

- The picture is shown whole, the full width of the window, instead of a row
  sized thumbnail beside a file name: it is the thing being placed, so that
  was the wrong size for it. The box shows the corrected colour texture, so
  moving a correction slider is visible in the panel as well as on the model;
  a grayscale picture has no colour texture and falls back to the height
  thumbnail. The file name stays underneath as a caption.

- Before anything is loaded the same box is an empty frame with a plus in it,
  in the same place and the same size, rather than a button of its own.
  Clicking either state opens the library, which is also where Import lives.

- Tile, off by default. A photo or a logo goes on once, so a new layer the
  Image tool creates starts with a single copy and Size sets how big that
  copy is. It is a default and not a pin, so the checkbox can turn it on.

- Move, the on-canvas placement handle the displacement tool has: drag the
  handle on the surface to slide the picture, or one of its arrows to slide
  it along one axis.

- Detail, the bake resolution, with the budget pinned to a million triangles.
  Each triangle prints in one filament, so this is how sharp an edge between
  two colours can come out, and it is the only thing left that decides it.
  The auto budget is 750 k, chosen for relief where triangles buy shape;
  here they buy the whole output, so it is worth spending more. The slider is
  seeded with the resolution Auto would have picked for the model, and warns
  when what it asks for no longer fits the budget.

Also fixes a short circuit in the panel: apply_standard_mode_presets(mv) ||
apply_image_mode_presets(mv) skipped the image presets on any frame the
standard ones changed something.

Measured with the texture-bake harness on the tool's shipped configuration
(tiling off, million-triangle budget, the seeded resolution), one cube face,
four filaments:

  run                      out tris   coloured  max offset mm  filament shares
  relief, depth 0.4 mm        41040      33902       0.216000  1:91% 2:6% 3:3%
  image, no correction        12066       4052       0.000005  1:71% 2:25% 3:4%
  image, brightness +0.5       8208        268       0.000005  4:100%
  image, contrast +0.8        11980       3910       0.000005  1:78% 2:20% 3:1%
  image, saturation 0          8208        268       0.000005  3:100%
  image, gamma 2.2            13176       5175       0.000004  1:67% 4:33%
  image, negative              8208        268       0.000005  4:100%

Fewer coloured triangles than the previous run of the same scene because
tiling is now off: one copy covers part of the face rather than repeating
across it. The surface still does not move and every correction still changes
which filaments the picture prints in.
2026-10-12 11:12:57 +02:00
ExPikaPaka e003b61e17 feat: make the Image gizmo a minimal load, click, correct, apply tool
The first cut was the displacement panel with depth hidden, which left the
brushes, the eight layer slots, the UV editor, seams, tiling and the
mesh-preparation sections in a tool that needs none of them, and offered no
way to correct the picture at all. This replaces that panel and adds the
corrections.

The flow is now: load a picture, click the model to put it there, adjust it,
press Apply.

- The Image tool gets its own window, render_image_input_window(), defined in
  GLGizmoImage.cpp. on_render_input_window() dispatches to it on the first
  line, so the displacement panel goes back to exactly what it was rather
  than being threaded with tests for which tool is drawing.

- A click places the picture: the tool pins SMART_FILL with a pointer cursor,
  so one click floods the surface it lands on out to the first fold sharper
  than the Spread angle. No brush, no erase toggle.

- Projection keeps Flat, Around, Ball and From view. Unwrap is left out and a
  layer arriving on it falls back to Flat: seams, islands and the UV pane are
  an editor's worth of interface for a tool that wants a click and a slider.

- The preview is pinned to the fast one. With no relief to build it is both
  instant and exactly the final colour, since the shader's colour path does
  not depend on depth_mm.

- The window shows only what this case needs: the picture, Spread, the four
  projections, Size, Rotation, the corrections, Apply and Close.

Colour correction is new in libslic3r. TextureDisplacementLayer gains
brightness, contrast, saturation, gamma and color_invert, all defaulting to
the identity, and apply_color_adjustments() applies them to an image's RGB
only, never to the height: a brightness slider has no business moving
geometry. It runs at the single point every bake and preview job reads a
layer's colour from, prepare_sampleable_layers(), and at the two caches that
would otherwise miss a change the image_data pointer does not show: the fast
preview's GPU texture and the mix ranking.

Measured with the texture-bake harness, one cube face painted with a colour
image, four filaments:

  run                      out tris   coloured  max offset mm  filament shares
  relief, depth 0.4 mm        41040      33902       0.216000  1:91% 2:6% 3:3%
  image, no correction        24226      17090       0.000007  1:71% 2:25% 3:4%
  image, brightness +0.5       8204       1068       0.000004  4:100%
  image, contrast +0.8        22688      15552       0.000007  1:75% 2:24% 3:1%
  image, saturation 0          8204       1068       0.000004  3:100%
  image, gamma 2.2            24754      17618       0.000007  1:66% 4:34%
  image, negative              8204       1068       0.000004  4:100%

Max offset is the distance from every output vertex to the original surface,
not a vertex-array diff: the bake refines, so a new vertex only counts as
unmoved if it lies in the old surface. Every correction moves which filaments
the picture prints in, and none of them moves the surface.
2026-10-12 10:20:44 +02:00
ExPikaPaka a800e0c186 feat: add an Image gizmo for zero-displacement colour
A separate toolbar tool for the simple case of texture displacement: put a
colour picture on the model with no relief at all.

It is a thin wrapper, not a fork. GLGizmoImage subclasses
GLGizmoTextureDisplacement and overrides one new protected virtual,
image_mode(). Painting, the layer stack, the paint masks, the texture
library, the previews and the bake are the same code reached the same way;
a zero-depth bake refines the painted area and writes the per-triangle
filament indices exactly as a relief bake does, it just displaces by
nothing.

In the base gizmo, image_mode() does three things:

- apply_image_mode_presets() pins every layer to depth 0 and turns colour on
  wherever the image has any, alongside the existing Standard-mode presets.
- The panel leaves out Depth, Midlevel, Invert, Edge fade and Blend, and the
  Standard/Pro switch, which is what keeps the mesh-preparation sections
  hidden. Bake is disabled when no image carries colour, since that bake
  would refine the mesh and produce nothing.
- add_texture_layer() seeds a new layer from the first library texture that
  has colour, rather than the first one, because most of the shipped library
  is grayscale height maps.

The two tools share one layer stack, so opening the Image tool on a volume
whose layers carry relief flattens them. Only depth changes: the paint, the
image, the tiling, the mapping and any relief already baked into the mesh
are untouched.

The toolbar icon follows Orca's menu-icon style: 16 px, 1 px #949494 frame,
#009688 on the picture inside.

The shortcut is registered but unbound: every single letter the Plater
context can reach is already taken, and a default that shadows one of them
is worse than none. docs/HLSD/keyboard-shortcuts.md now documents that an
empty default chord is a supported choice.

Measured with a probe built against this tree in the texture-bake harness:
a depth-0 bake of a painted cube face leaves every output vertex within
7 nm of the original surface (tolerance 1 um) while colouring 17090 of
24226 output triangles across 3 filaments; the same scene at depth 0.4 mm
moves vertices up to 0.216 mm.
2026-10-12 08:29:10 +02:00
37 changed files with 3014 additions and 243 deletions
+29
View File
@@ -0,0 +1,29 @@
name: 'Deploy Nightly'
description: 'Upload a file to a release, replacing the asset of the same name'
inputs:
asset_path:
description: 'Path of the file to upload'
required: true
asset_name:
description: 'Asset name on the release'
required: true
content_type:
description: 'Content type of the uploaded file'
required: false
default: 'application/octet-stream'
tag:
description: 'Tag of the release to upload to'
required: false
default: 'nightly-builds'
runs:
using: 'composite'
steps:
- name: Replace release asset
shell: bash
env:
GH_TOKEN: ${{ github.token }}
ASSET_PATH: ${{ inputs.asset_path }}
ASSET_NAME: ${{ inputs.asset_name }}
CONTENT_TYPE: ${{ inputs.content_type }}
RELEASE_TAG: ${{ inputs.tag }}
run: bash "$GITHUB_ACTION_PATH/deploy.sh"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Replaces one asset of a GitHub release, uploading under a temporary name
# first so a failed upload leaves the old asset in place.
#
# Environment: GH_TOKEN, ASSET_PATH, ASSET_NAME, CONTENT_TYPE, RELEASE_TAG and
# the GITHUB_* variables Actions sets.
# The $names inside the single-quoted jq programs are jq variables.
# shellcheck disable=SC2016
set -euo pipefail
repo_api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY"
if [ ! -f "$ASSET_PATH" ]; then
echo "::error::$ASSET_PATH does not exist"
exit 1
fi
# The speed limit turns a stalled transfer into an error that --retry handles.
curl_opts=(-sS --retry 3 --connect-timeout 30 --speed-limit 1024 --speed-time 60
-H "Authorization: Bearer $GH_TOKEN")
# Prints the response body, or reports GitHub's error body and fails.
api() {
local out
if ! out=$(curl "${curl_opts[@]}" --fail-with-body "$@"); then
# A workflow command ends at the first newline.
echo "::error::GitHub API request failed: ${out//[$'\r\n']/ }" >&2
return 1
fi
printf '%s\n' "$out"
}
http_status() {
curl "${curl_opts[@]}" -o /dev/null -w '%{http_code}' "$@"
}
# jq prints CRLF on Windows.
jqr() {
jq -r "$@" | tr -d '\r'
}
delete_asset() {
local code
code=$(http_status -X DELETE "$repo_api/releases/assets/$1")
case $code in
# A 404 means a concurrent run already deleted the asset.
204 | 404) ;;
*)
echo "::error::Deleting release asset $1 failed with HTTP $code" >&2
return 1
;;
esac
}
list_assets() {
local page=1 json
while :; do
# Callers run this inside $(...), where bash ignores set -e.
json=$(api "$repo_api/releases/$release_id/assets?per_page=100&page=$page") || exit 1
printf '%s\n' "$json"
[ "$(jqr length <<<"$json")" -eq 100 ] || break
page=$((page + 1))
done | jq -s add
}
release=$(api "$repo_api/releases/tags/$RELEASE_TAG")
release_id=$(jqr .id <<<"$release")
upload_url=$(jqr .upload_url <<<"$release")
upload_url=${upload_url%%\{*}
# Unique per run attempt, so concurrent runs never upload to the same name.
tmp_name="$ASSET_NAME.partial-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
# Deletes leftover partials older than an hour, an age no upload in progress reaches.
stale_ids=$(list_assets | jqr --arg prefix "$ASSET_NAME.partial-" \
'.[] | select((.name | startswith($prefix)) and (.created_at | fromdateiso8601) < now - 3600) | .id')
for id in $stale_ids; do
delete_asset "$id"
done
name_query=$(jqr -n --arg name "$tmp_name" '$name | @uri')
# Uploads once, since a retry after an upload that succeeded fails on the taken name.
new_id=$(api --retry 0 -X POST -H "Content-Type: $CONTENT_TYPE" -T "$ASSET_PATH" \
"$upload_url?name=$name_query" | jqr .id)
trap 'delete_asset "$new_id" || true' EXIT
rename_body=$(jq -n --arg name "$ASSET_NAME" '{name: $name}')
# Retries on HTTP 422, which a concurrent run causes by renaming its own upload
# between this delete and this rename.
for _ in 1 2 3; do
old_ids=$(list_assets | jqr --arg name "$ASSET_NAME" '.[] | select(.name == $name) | .id')
for id in $old_ids; do
delete_asset "$id"
done
code=$(http_status -X PATCH -d "$rename_body" "$repo_api/releases/assets/$new_id")
if [ "$code" = 200 ]; then
trap - EXIT
exit 0
fi
[ "$code" = 422 ] || break
done
echo "::error::Renaming $tmp_name to $ASSET_NAME failed with HTTP $code" >&2
exit 1
+1 -5
View File
@@ -441,14 +441,10 @@ jobs:
path: '/__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak'
- name: Deploy Flatpak to nightly release
if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main'
uses: WebFreak001/deploy-nightly@v3.2.0
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: /__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak
asset_name: OrcaSlicer-Linux-flatpak_nightly_${{ matrix.variant.arch }}.flatpak
asset_content_type: application/octet-stream
max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted
# The asset is /app (the exes link it at runtime) plus the build tree
# slimmed to what ctest needs.
- name: Package flatpak test asset
+14 -46
View File
@@ -397,25 +397,18 @@ jobs:
- name: Deploy Mac release
if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'macOS' && inputs.macos-combine-only && !vars.SELF_HOSTED
uses: WebFreak001/deploy-nightly@v3.2.0
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ${{ github.workspace }}/OrcaSlicer_Mac_universal_${{ env.ver }}.dmg
asset_name: OrcaSlicer_Mac_universal_nightly.dmg
asset_content_type: application/octet-stream
max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted
# The DMG is only built when the validator app was.
- name: Deploy Mac OrcaSlicer_profile_validator DMG release
if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'macOS' && inputs.macos-combine-only && !vars.SELF_HOSTED
uses: WebFreak001/deploy-nightly@v3.2.0
if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'macOS' && inputs.macos-combine-only && !vars.SELF_HOSTED && hashFiles(format('OrcaSlicer_profile_validator_Mac_universal_{0}.dmg', env.ver)) != ''
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ${{ github.workspace }}/OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg
asset_name: OrcaSlicer_profile_validator_Mac_universal_nightly.dmg
asset_content_type: application/octet-stream
max_releases: 1
# Windows
- name: Set Windows build variables
@@ -542,36 +535,27 @@ jobs:
- name: Deploy Windows release portable
if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && !vars.SELF_HOSTED
uses: WebFreak001/deploy-nightly@v3.2.0
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer_Windows_${{ env.ver }}${{ env.ARCH_SUFFIX }}_portable.zip
asset_name: OrcaSlicer_Windows${{ env.ARCH_SUFFIX }}_nightly_portable.zip
asset_content_type: application/x-zip-compressed
max_releases: 1
content_type: application/x-zip-compressed
- name: Deploy Windows release installer
if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && !vars.SELF_HOSTED
uses: WebFreak001/deploy-nightly@v3.2.0
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer_Windows_Installer_${{ env.ver }}${{ env.ARCH_SUFFIX }}.exe
asset_name: OrcaSlicer_Windows_Installer${{ env.ARCH_SUFFIX }}_nightly.exe
asset_content_type: application/x-msdownload
max_releases: 1
content_type: application/x-msdownload
- name: Deploy Windows OrcaSlicer_profile_validator release
if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && inputs.arch != 'arm64' && !vars.SELF_HOSTED
uses: WebFreak001/deploy-nightly@v3.2.0
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ${{ github.workspace }}/build/src/Release/OrcaSlicer_profile_validator.exe
asset_name: OrcaSlicer_profile_validator_Windows_nightly.exe
asset_content_type: application/x-msdownload
max_releases: 1
content_type: application/x-msdownload
- name: Build MSIX Store package Win
if: runner.os == 'Windows' && !vars.SELF_HOSTED
@@ -700,14 +684,10 @@ jobs:
- name: Deploy Ubuntu release
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED }}
uses: WebFreak001/deploy-nightly@v3.2.0
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ./build/OrcaSlicer_Linux_AppImage${{ env.ubuntu-ver-str }}${{ env.arch_suffix }}_${{ env.ver }}.AppImage
asset_name: OrcaSlicer_Linux_AppImage${{ env.ubuntu-ver-str }}${{ env.arch_suffix }}_nightly.AppImage
asset_content_type: application/octet-stream
max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted
- name: Deploy Ubuntu release
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }}
run: |
@@ -716,36 +696,24 @@ jobs:
- name: Deploy Ubuntu OrcaSlicer_profile_validator release
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }}
uses: WebFreak001/deploy-nightly@v3.2.0
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ./build/src/Release/OrcaSlicer_profile_validator
asset_name: OrcaSlicer_profile_validator_Linux${{ env.ubuntu-ver-str }}_nightly
asset_content_type: application/octet-stream
max_releases: 1
- name: Deploy Ubuntu generate_system_cache release
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }}
uses: WebFreak001/deploy-nightly@v3.2.0
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ./build/src/dev-utils/Release/generate_system_cache
asset_name: generate_system_cache_Linux${{ env.ubuntu-ver-str }}_nightly
asset_content_type: application/octet-stream
max_releases: 1
- name: Deploy orca_custom_preset_tests
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }}
uses: WebFreak001/deploy-nightly@v3.2.0
uses: ./.github/actions/deploy-nightly
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ${{ github.workspace }}/resources/profiles/orca_custom_preset_tests.zip
asset_name: orca_custom_preset_tests.zip
asset_content_type: application/octet-stream
max_releases: 1
# The build has just touched everything it can use, so an object
# untouched for a week is dead, usually orphaned by a flag change.
+3 -1
View File
@@ -124,7 +124,9 @@ Control on the Global page, and from the `?` key on the page of the view that re
the dialog should list it; the row's section heading is the `section_table` entry
above it, so a new section needs an entry there too. Pick a default that does not
collide inside its contexts; the `[Shortcuts]` tests check every default against the
others.
others. A default `KeyChord{}` leaves the action unbound: it is listed in the dialog
for the user to bind and matches nothing until they do, which is the honest answer
when every key its contexts can reach is already spoken for.
2. Handle it in the dispatcher of its context: `MainFrame::handle_global_shortcut`,
`GLCanvas3D::handle_shortcut`, `ObjectList::dispatch_shortcut`, or a gizmo's
`on_tool_shortcut`. A gizmo that opens on a key sets `m_shortcut` in its constructor.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2.5,2.5h11v11h-11z" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><path d="M8,2.5v11M2.5,8h11" style="fill:none;stroke:#949494;stroke-dasharray:1.5,1.5"/><path d="M4.5,11.5l3.5-6l3.5,6z" style="fill:#009688"/></svg>

After

Width:  |  Height:  |  Size: 340 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g transform="rotate(-45 8 8)"><path d="M8 14.4 6.5 11.2 9.5 11.2 Z" style="fill:#009688"/><path d="M6.5 7.3 6.5 11.2 8 14.4 9.5 11.2 9.5 7.3" style="fill:none;stroke:#949494;stroke-width:0.4;stroke-linejoin:round;stroke-linecap:round"/><rect x="5.5" y="3.8" width="5" height="3.5" rx="0.9" style="fill:none;stroke:#949494;stroke-width:0.4;stroke-linejoin:round"/></g></svg>

After

Width:  |  Height:  |  Size: 458 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><circle cx="5.9" cy="6.3" r="1.25" style="fill:#009688"/><path d="M3.4 11.9 6.4 7.7 8.8 10.7 10.4 8.6 12.6 11.9 Z" style="fill:#009688"/><rect x="2.375" y="2.375" width="11.25" height="11.25" rx="1.25" style="fill:none;stroke:#949494;stroke-width:0.4;stroke-linecap:round;stroke-linejoin:round"/></svg>

After

Width:  |  Height:  |  Size: 386 B

@@ -51,6 +51,11 @@ uniform float tex_aspect;
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool invert;
// False means the layer places a single copy rather than repeating: outside that copy there is
// simply no texture. DecodedHeightTexture::sample() has always returned 0 there, so the bake got
// this right while the preview wrapped the image and showed a tiled pattern that was never going
// to be printed.
uniform bool tile_enabled;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
@@ -230,6 +235,14 @@ int nearest_palette_entry(vec3 rgb)
return best;
}
// True where this uv has texture on it: everywhere when the layer repeats, and only inside the
// single placed copy when it does not. The bake's own sampler draws the same line (see
// DecodedHeightTexture::sample), so the preview now stops at the same edge it does.
bool in_tile(vec2 uv)
{
return tile_enabled || (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0);
}
void main()
{
if (any(lessThan(clipping_planes_dots, ZERO)))
@@ -255,9 +268,9 @@ void main()
? vec2(dot(island_delta_lin.xy, vertex_uv), dot(island_delta_lin.zw, vertex_uv)) + island_delta_tr
: vertex_uv;
color_uv = uv;
have_uv = true;
have_uv = in_tile(uv);
float h = texture2D(height_tex, uv).r;
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0) * (have_uv ? 1.0 : 0.0);
vec3 sigmaS = dFdx(world_pos.xyz);
vec3 sigmaT = dFdy(world_pos.xyz);
vec3 R1 = cross(sigmaT, triangle_normal);
@@ -274,10 +287,12 @@ void main()
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
// uv first: outside a single placed copy there is no shell to march through.
vec2 uv = project_uv(tex_pos, triangle_normal);
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0) *
(in_tile(uv) ? 1.0 : 0.0);
vec3 view_dir = normalize(eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(tex_pos, triangle_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
@@ -314,7 +329,7 @@ void main()
}
color_uv = uv; // after the parallax march, so colour and relief stay registered
have_uv = true;
have_uv = in_tile(uv);
float hL = texture2D(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r;
float hR = texture2D(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r;
@@ -323,7 +338,8 @@ void main()
vec2 dh_duv = vec2((hR - hL) / (2.0 * height_tex_texel.x), (hU - hD) / (2.0 * height_tex_texel.y));
float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0);
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0) *
(have_uv ? 1.0 : 0.0);
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
@@ -353,7 +369,12 @@ void main()
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
// the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb;
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
albedo = palette_rgb[nearest_palette_entry(texture2D(color_tex, color_uv).rgb)];
// The fourth channel is coverage: where the picture is not there, the surface keeps its own
// colour rather than being painted the black that a transparent texel's rgb reads as.
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) {
vec4 tex_rgba = texture2D(color_tex, color_uv);
if (tex_rgba.a >= 0.5)
albedo = palette_rgb[nearest_palette_entry(tex_rgba.rgb)];
}
gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
}
@@ -110,6 +110,11 @@ uniform float tex_aspect;
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool invert;
// False means the layer places a single copy rather than repeating: outside that copy there is
// simply no texture. DecodedHeightTexture::sample() has always returned 0 there, so the bake got
// this right while the preview wrapped the image and showed a tiled pattern that was never going
// to be printed.
uniform bool tile_enabled;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
@@ -296,6 +301,14 @@ int nearest_palette_entry(vec3 rgb)
return best;
}
// True where this uv has texture on it: everywhere when the layer repeats, and only inside the
// single placed copy when it does not. The bake's own sampler draws the same line (see
// DecodedHeightTexture::sample), so the preview now stops at the same edge it does.
bool in_tile(vec2 uv)
{
return tile_enabled || (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0);
}
void main()
{
if (any(lessThan(clipping_planes_dots, ZERO)))
@@ -333,9 +346,9 @@ void main()
? vec2(dot(island_delta_lin.xy, vertex_uv), dot(island_delta_lin.zw, vertex_uv)) + island_delta_tr
: vertex_uv;
color_uv = uv;
have_uv = true;
have_uv = in_tile(uv);
float h = texture(height_tex, uv).r;
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
float k = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0) * (have_uv ? 1.0 : 0.0);
vec3 sigmaS = dFdx(world_pos.xyz);
vec3 sigmaT = dFdy(world_pos.xyz);
vec3 R1 = cross(sigmaT, triangle_normal);
@@ -355,10 +368,12 @@ void main()
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
// uv first: outside a single placed copy there is no shell to march through.
vec2 uv = project_uv(tex_pos, triangle_normal);
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0) *
(in_tile(uv) ? 1.0 : 0.0);
vec3 view_dir = normalize(eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(tex_pos, triangle_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
@@ -395,7 +410,7 @@ void main()
}
color_uv = uv; // after the parallax march, so colour and relief stay registered
have_uv = true;
have_uv = in_tile(uv);
float hL = texture(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r;
float hR = texture(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r;
@@ -408,7 +423,8 @@ void main()
// uv -> mm is 1/tiling_scale for the triplanar projection, so this turns the uv-space
// gradient into a real surface slope.
float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0);
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0) *
(have_uv ? 1.0 : 0.0);
// uv was rotated by project_uv() while t/b are the unrotated model axes, so rotate the
// gradient back into the axes' frame.
float cs = cos(rotation_rad);
@@ -441,7 +457,12 @@ void main()
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
// the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb;
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
albedo = palette_rgb[nearest_palette_entry(texture(color_tex, color_uv).rgb)];
// The fourth channel is coverage: where the picture is not there, the surface keeps its own
// colour rather than being painted the black that a transparent texel's rgb reads as.
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) {
vec4 tex_rgba = texture(color_tex, color_uv);
if (tex_rgba.a >= 0.5)
albedo = palette_rgb[nearest_palette_entry(tex_rgba.rgb)];
}
out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
}
+1 -11
View File
@@ -4,7 +4,6 @@
#include "WipeTower.hpp"
#include "WipeTower2.hpp"
#include "../Config.hpp"
#include "../FilamentMixer.hpp"
#include "../PrintConfig.hpp"
#include "../libslic3r.h"
@@ -75,19 +74,10 @@ Polygon estimate_wipe_tower_first_layer_outline(const ConfigBase &config, WipeTo
return WipeTower2::cone_base_polygon(width, depth, height, cone ? cone_angle->getFloat() : 0.);
}
WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, WipeTowerType tower_type, const std::vector<unsigned int> &filament_slots, double layer_height, double max_object_height)
WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, WipeTowerType tower_type, const std::vector<unsigned int> &filament_ids, double layer_height, double max_object_height)
{
WipeTowerFootprint footprint;
footprint.height = max_object_height;
// A mixed slot is virtual: the tool ordering resolves it to one of its physical components
// on every layer, with or without colour sublayers, so the tower only ever purges those.
// Validation passes the slots as Print::extruders() lists them; the GUI and CLI placement
// expand them beforehand, which repeating here leaves unchanged.
std::vector<unsigned int> filament_ids = filament_slots;
const auto *is_mixed_opt = dynamic_cast<const ConfigOptionBools *>(option_of(config, "filament_is_mixed"));
const auto *components_opt = dynamic_cast<const ConfigOptionStrings *>(option_of(config, "filament_mixed_components"));
if (is_mixed_opt != nullptr && components_opt != nullptr && has_any_mixed_filament(is_mixed_opt->values))
filament_ids = expand_mixed_filaments(filament_slots, is_mixed_opt->values, components_opt->values);
const size_t filaments_cnt = filament_ids.size();
if (filaments_cnt == 0 || layer_height < EPSILON)
return footprint;
@@ -34,8 +34,6 @@ Polygon estimate_wipe_tower_first_layer_outline(const ConfigBase &config, WipeTo
// filament_ids: 0-based filaments purged on the plate. The config cannot see custom G-code tool
// changes, so ids derived from the model must include them
// (Print::extruders(true)) or a real tower is sized as if it were never built.
// A mixed slot may be passed as itself or already expanded: it is sized as its
// physical components either way, since those are all the tower ever purges.
// layer_height: thinnest layer the objects are sliced at. The first layer is folded in here.
//
// A raft is deliberately not a reason: normalize_fdm_2 clears enable_prime_tower for a plate
+22
View File
@@ -354,6 +354,24 @@ Model Model::read_from_file(const std::string&
ObjInfo obj_info;
ObjParser::MtlData mtl_data;
result = load_obj(input_file.c_str(), &model, obj_info, message, nullptr, &mtl_data);
// Keep the model's own UV channel on the volume, whatever the colour import then does with
// the mesh: it is what TextureProjectionMethod::ModelUV maps a texture-displacement layer
// through, so a model that arrives with a UV layout is textured on that layout instead of on
// a fresh unwrap. load_obj() fills obj_info.uvs per face of the mesh it built, in the same
// order (and only when every face named a known material, hence the size check). V is flipped
// on the way in, to the convention DecodedHeightTexture samples in - the same flip
// obj_to_textured_mesh() does for the colour importer.
if (result && !obj_info.uvs.empty() && !model.objects.empty() && !model.objects.back()->volumes.empty()) {
ModelVolume &volume = *model.objects.back()->volumes[0];
const indexed_triangle_set &its = volume.mesh().its;
if (obj_info.uvs.size() == its.indices.size()) {
volume.texture_uv.resize(its.indices.size() * 3);
for (size_t fi = 0; fi < obj_info.uvs.size(); ++fi)
for (int k = 0; k < 3; ++k)
volume.texture_uv[fi * 3 + size_t(k)] =
Vec2f(obj_info.uvs[fi][size_t(k)].x(), 1.f - obj_info.uvs[fi][size_t(k)].y());
}
}
if (result && obj_info.has_uv_png && !obj_info.uvs.empty() && !model.objects.empty()) {
// Textured OBJ: hand the mesh + materials to the texture-to-color importer
// instead of the flat per-face colour dialog.
@@ -2134,6 +2152,10 @@ void ModelVolume::reset_extra_facets()
// than left referring to a mesh that no longer matches it.
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
this->texture_displacement_facet(i).reset();
// Same reasoning for the model's own UV channel: it is indexed by triangle, so against a mesh
// this no longer matches it would map the texture by another mesh's triangle numbering. A caller
// that can carry it across - see transfer_uv_channel() - puts it back after this.
this->reset_texture_uv();
}
std::optional<TriangleSelector::SavedPainting> ModelVolume::save_painting() const
+23 -2
View File
@@ -975,6 +975,26 @@ public:
// they are not a property of any single layer.
TextureDisplacementOptions texture_displacement_options;
// The model's own texture coordinates, as the file it was imported from carried them: three per
// triangle of mesh().its, in its triangle order (corner k of triangle i at 3i+k), V already
// flipped to the sampling convention (V = 0 is the image's top row). Empty when the source had
// none, which is every STL and every 3mf - only a textured OBJ brings one in today.
//
// Per corner rather than per vertex because that is what a UV layout is: the two sides of a seam
// are the same vertex with two different coordinates, and collapsing them would smear the texture
// across every island boundary. TextureProjectionMethod::ModelUV maps through it, which is what
// lets a model that already carries a UV channel be textured on the artist's own layout instead
// of on a fresh unwrap.
//
// Indexed by triangle, so anything that *replaces* the mesh has to bring it across (or drop it):
// see transfer_uv_channel(), and reset_texture_uv() for the cases that cannot.
std::vector<Vec2f> texture_uv;
bool has_texture_uv() const { return !texture_uv.empty() && texture_uv.size() == mesh().its.indices.size() * 3; }
// Forgets the channel. For a mesh change that nothing can carry it across - a split, a boolean -
// where leaving it would map the texture by another mesh's triangle numbering.
void reset_texture_uv() { texture_uv.clear(); }
// Save painting data before reset_extra_facets() discards it.
// Used for replacing mesh without losing painting data.
// Only for model parts (not modifiers/connectors).
@@ -1240,6 +1260,7 @@ private:
texture_displacement_facets_6(other.texture_displacement_facets_6), texture_displacement_facets_7(other.texture_displacement_facets_7),
texture_displacement_layers(other.texture_displacement_layers),
texture_displacement_options(other.texture_displacement_options),
texture_uv(other.texture_uv),
cut_info(other.cut_info), text_configuration(other.text_configuration), emboss_shape(other.emboss_shape)
{
assert(this->id().valid());
@@ -1338,7 +1359,7 @@ private:
cereal::load_by_value(ar, f);
mesh_changed |= tf != f.timestamp();
}
ar(texture_displacement_layers, texture_displacement_options);
ar(texture_displacement_layers, texture_displacement_options, texture_uv);
cereal::load_by_value(ar, config);
cereal::load(ar, text_configuration);
cereal::load(ar, emboss_shape);
@@ -1362,7 +1383,7 @@ private:
cereal::save_by_value(ar, fuzzy_skin_facets);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
cereal::save_by_value(ar, texture_displacement_facet(i));
ar(texture_displacement_layers, texture_displacement_options);
ar(texture_displacement_layers, texture_displacement_options, texture_uv);
cereal::save_by_value(ar, config);
cereal::save(ar, text_configuration);
cereal::save(ar, emboss_shape);
+564 -63
View File
@@ -116,6 +116,20 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi
return top * (1.f - ty) + bottom * ty;
}
float DecodedHeightTexture::sample_alpha(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
{
TexelTap tap;
if (!has_alpha())
return 1.f; // no coverage plane means the picture is everywhere, which is the usual case
if (!texel_tap(uv, tile_enabled, tile_method, tap))
return 0.f;
auto at = [this](int x, int y) { return float(alpha[size_t(y) * size_t(width) + size_t(x)]) / 255.f; };
const float top = at(tap.x0, tap.y0) * (1.f - tap.tx) + at(tap.x1, tap.y0) * tap.tx;
const float bottom = at(tap.x0, tap.y1) * (1.f - tap.tx) + at(tap.x1, tap.y1) * tap.tx;
return top * (1.f - tap.ty) + bottom * tap.ty;
}
Vec3f DecodedHeightTexture::sample_color(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const
{
TexelTap tap;
@@ -283,6 +297,272 @@ bool height_texture_has_color(const TextureDisplacementLayer &layer)
return decode_height_texture(layer).has_color();
}
namespace {
// Unsharp mask over the colour: blur a copy and push each texel away from it. The blur is the same
// sliding-window helper the Smoothing slider uses, run per channel, so the cost is the image size
// rather than the image size times the radius.
//
// Worth having here for a reason it would not be in a photo editor: the picture is about to be
// reduced to a handful of flat colours, and a soft edge between two of them quantizes into a ragged
// band of both. Sharpening first makes that boundary land in one place.
void sharpen_rgb(DecodedHeightTexture &tex, float amount)
{
if (!tex.has_color() || amount <= 0.f)
return;
const size_t n = size_t(tex.width) * size_t(tex.height);
// A small radius, scaled with the image so a 4k picture is not sharpened a tenth as hard as a
// 512 one. Deliberately not tied to `amount`: that is how much, this is how wide.
const int radius = std::max(1, int(std::lround(double(std::min(tex.width, tex.height)) / 512.0)));
const float k = std::clamp(amount, 0.f, 1.f) * 1.5f;
std::vector<uint8_t> plane(n);
for (int c = 0; c < 3; ++c) {
for (size_t i = 0; i < n; ++i)
plane[i] = tex.rgb[i * 3 + size_t(c)];
smooth_height_pixels_box(plane, tex.width, tex.height, radius);
for (size_t i = 0; i < n; ++i) {
const float sharp = float(tex.rgb[i * 3 + size_t(c)]) + k * (float(tex.rgb[i * 3 + size_t(c)]) - float(plane[i]));
tex.rgb[i * 3 + size_t(c)] = uint8_t(std::lround(std::clamp(sharp, 0.f, 255.f)));
}
}
}
// Reduces the picture to at most `count` colours, by k-means in RGB.
//
// "At most": two of the chosen colours can still match the same filament, in which case the print
// uses fewer. The guarantee runs the right way round, which is the one that matters - asking for
// three colours cannot produce four.
//
// k-means rather than a fixed palette or an even split of the luma range, because the useful
// question is which colours this picture is actually made of. A tartan asks for its own handful of
// greens and reds; posterizing the range would hand it eight shades of mud instead. The clustering
// runs over a coarse histogram, not over the pixels: at 1024 x 1024 that is 4096 weighted points
// instead of a million, which is what makes it cheap enough to sit on a slider.
void reduce_colors(DecodedHeightTexture &tex, int count)
{
if (!tex.has_color() || count < 2 || count > 64)
return;
const size_t n = size_t(tex.width) * size_t(tex.height);
// 16 levels per channel, each bin keeping the mean of what fell in it, so a colour is not
// snapped to a bin corner before the clustering has had a say.
struct Bin { double r = 0, g = 0, b = 0; uint32_t n = 0; };
std::vector<Bin> bins(size_t(16 * 16 * 16));
for (size_t i = 0; i < n; ++i) {
const uint8_t r = tex.rgb[i * 3], g = tex.rgb[i * 3 + 1], b = tex.rgb[i * 3 + 2];
Bin &bin = bins[(size_t(r >> 4) << 8) | (size_t(g >> 4) << 4) | size_t(b >> 4)];
bin.r += r; bin.g += g; bin.b += b; ++bin.n;
}
struct Point { float c[3]; double w; };
std::vector<Point> pts;
pts.reserve(bins.size());
for (const Bin &bin : bins)
if (bin.n > 0)
pts.push_back({ { float(bin.r / bin.n), float(bin.g / bin.n), float(bin.b / bin.n) }, double(bin.n) });
if (pts.size() <= size_t(count))
return; // already no more colours than asked for
// Seeded the k-means++ way, deterministically: the heaviest point first, then each next one is
// whichever point is furthest from everything chosen so far, weighted by how much of the image
// it is. Picking the N fullest bins instead would put every centre inside the one dominant
// colour of a picture that has one.
std::vector<std::array<float, 3>> centre;
{
size_t best = 0;
for (size_t i = 1; i < pts.size(); ++i)
if (pts[i].w > pts[best].w)
best = i;
centre.push_back({ pts[best].c[0], pts[best].c[1], pts[best].c[2] });
std::vector<double> d2(pts.size(), std::numeric_limits<double>::max());
while (int(centre.size()) < count) {
const auto &last = centre.back();
double top = -1.0;
size_t pick = 0;
for (size_t i = 0; i < pts.size(); ++i) {
const double dr = pts[i].c[0] - last[0], dg = pts[i].c[1] - last[1], db = pts[i].c[2] - last[2];
d2[i] = std::min(d2[i], dr * dr + dg * dg + db * db);
if (const double score = d2[i] * pts[i].w; score > top) {
top = score;
pick = i;
}
}
if (top <= 0.0)
break; // fewer distinct colours than asked for
centre.push_back({ pts[pick].c[0], pts[pick].c[1], pts[pick].c[2] });
}
}
// Lloyd's algorithm over the weighted bins. Twelve passes: on a histogram this coarse it has
// stopped moving well before that, and a fixed count keeps the slider's cost predictable.
const int k = int(centre.size());
for (int iter = 0; iter < 12; ++iter) {
std::vector<std::array<double, 3>> sum(size_t(k), { 0.0, 0.0, 0.0 });
std::vector<double> wsum(size_t(k), 0.0);
for (const Point &p : pts) {
int best = 0;
double bd = std::numeric_limits<double>::max();
for (int c = 0; c < k; ++c) {
const double dr = p.c[0] - centre[size_t(c)][0], dg = p.c[1] - centre[size_t(c)][1],
db = p.c[2] - centre[size_t(c)][2];
if (const double d = dr * dr + dg * dg + db * db; d < bd) { bd = d; best = c; }
}
for (int ch = 0; ch < 3; ++ch)
sum[size_t(best)][size_t(ch)] += double(p.c[ch]) * p.w;
wsum[size_t(best)] += p.w;
}
for (int c = 0; c < k; ++c)
if (wsum[size_t(c)] > 0.0)
for (int ch = 0; ch < 3; ++ch)
centre[size_t(c)][size_t(ch)] = float(sum[size_t(c)][size_t(ch)] / wsum[size_t(c)]);
}
// Every texel takes its nearest centre. Parallel by rows, as the tonal pass is.
const size_t rows = size_t(tex.height), cols = size_t(tex.width);
tbb::parallel_for(tbb::blocked_range<size_t>(0, rows), [&](const tbb::blocked_range<size_t> &range) {
for (size_t y = range.begin(); y < range.end(); ++y)
for (size_t x = 0; x < cols; ++x) {
uint8_t *px = tex.rgb.data() + (y * cols + x) * 3;
int best = 0;
float bd = std::numeric_limits<float>::max();
for (int c = 0; c < k; ++c) {
const float dr = float(px[0]) - centre[size_t(c)][0], dg = float(px[1]) - centre[size_t(c)][1],
db = float(px[2]) - centre[size_t(c)][2];
if (const float d = dr * dr + dg * dg + db * db; d < bd) { bd = d; best = c; }
}
for (int ch = 0; ch < 3; ++ch)
px[size_t(ch)] = uint8_t(std::lround(std::clamp(centre[size_t(best)][size_t(ch)], 0.f, 255.f)));
}
});
}
// Keys one colour out, so a logo on a white card prints as the logo and the object's own colour
// shows through the rest.
//
// The corners are the default because a background that does not reach all four of them is not a
// background, and that makes the common case one click; the median of the four is taken so a single
// stray corner cannot decide it. The panel's dropper and swatch override it when the picture needs
// something else keyed out.
void remove_background(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer)
{
if (!tex.has_color() || !layer.bg_remove || tex.width < 2 || tex.height < 2)
return;
const size_t n = size_t(tex.width) * size_t(tex.height);
float key[3];
if (layer.bg_from_corners) {
const size_t corner[4] = { 0, size_t(tex.width) - 1, (size_t(tex.height) - 1) * size_t(tex.width),
n - 1 };
for (int c = 0; c < 3; ++c) {
std::array<float, 4> v{};
for (int i = 0; i < 4; ++i)
v[size_t(i)] = float(tex.rgb[corner[i] * 3 + size_t(c)]);
std::sort(v.begin(), v.end());
key[c] = 0.5f * (v[1] + v[2]);
}
} else {
for (int c = 0; c < 3; ++c)
key[c] = std::clamp(layer.bg_color[c], 0.f, 1.f) * 255.f;
}
// Euclidean in 0..255 RGB, against a tolerance scaled to the diagonal of that cube, so the
// slider reads as "how different from the background still counts as background".
const float limit = std::clamp(layer.bg_tolerance, 0.f, 1.f) * 441.67f;
if (!tex.has_alpha())
tex.alpha.assign(n, 255);
for (size_t i = 0; i < n; ++i) {
const float dr = float(tex.rgb[i * 3]) - key[0];
const float dg = float(tex.rgb[i * 3 + 1]) - key[1];
const float db = float(tex.rgb[i * 3 + 2]) - key[2];
if (std::sqrt(dr * dr + dg * dg + db * db) <= limit)
tex.alpha[i] = 0;
}
}
} // namespace
bool has_color_adjustments(const TextureDisplacementLayer &layer)
{
return layer.brightness != 0.f || layer.contrast != 0.f || layer.saturation != 1.f ||
layer.gamma != 1.f || layer.color_invert || layer.hue != 0.f || layer.sharpness > 0.f ||
layer.color_count >= 2 || layer.bg_remove;
}
void apply_color_adjustments(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer)
{
if (!tex.has_color() || !has_color_adjustments(layer))
return;
// A contrast of -1 collapses the image to mid grey and +1 doubles the slope about it, which is
// the range a single slider can offer without a scale nobody can read off the handle.
const float slope = 1.f + std::clamp(layer.contrast, -1.f, 1.f);
const float bright = std::clamp(layer.brightness, -1.f, 1.f);
const float sat = std::max(0.f, layer.saturation);
const float inv_g = 1.f / std::max(0.01f, layer.gamma);
const bool negate = layer.color_invert;
// Hue is a rotation of the colour about the grey axis. Rodrigues' formula about (1,1,1)/sqrt(3),
// then the luma is put back exactly (the rotation preserves R+G+B, not the Rec. 709 weighting),
// so turning the wheel changes which filament an area matches without also changing how light
// it is.
const float hue_rad = layer.hue * float(M_PI) / 180.f;
const float hue_cos = std::cos(hue_rad), hue_sin = std::sin(hue_rad);
const bool rotate = layer.hue != 0.f;
constexpr float LW[3] = { 0.2126f, 0.7152f, 0.0722f }; // Rec. 709 luma
// Each output byte depends only on its own pixel, so the rows are independent. At 1024 x 1024
// this runs on every preview rebuild and every bake, which is what makes it worth splitting.
const size_t rows = size_t(tex.height);
const size_t cols = size_t(tex.width);
tbb::parallel_for(tbb::blocked_range<size_t>(0, rows), [&](const tbb::blocked_range<size_t> &range) {
for (size_t y = range.begin(); y < range.end(); ++y) {
uint8_t *row = tex.rgb.data() + y * cols * 3;
for (size_t x = 0; x < cols; ++x) {
uint8_t *px = row + x * 3;
float c[3];
for (int k = 0; k < 3; ++k) {
float v = float(px[k]) * (1.f / 255.f);
v = std::pow(v, inv_g);
v = (v - 0.5f) * slope + 0.5f;
v += bright;
c[k] = v;
}
if (rotate) {
const float luma = LW[0] * c[0] + LW[1] * c[1] + LW[2] * c[2];
// The component of the colour across the grey axis, which is what a hue is.
const float mean = (c[0] + c[1] + c[2]) / 3.f;
const float p[3] = { c[0] - mean, c[1] - mean, c[2] - mean };
// k x p for the unit grey axis k = (1,1,1)/sqrt(3).
constexpr float INV_SQRT3 = 0.57735027f;
const float x[3] = { INV_SQRT3 * (p[1] - p[2]), INV_SQRT3 * (p[2] - p[0]),
INV_SQRT3 * (p[0] - p[1]) };
for (int k = 0; k < 3; ++k)
c[k] = mean + p[k] * hue_cos + x[k] * hue_sin;
const float luma_now = LW[0] * c[0] + LW[1] * c[1] + LW[2] * c[2];
for (float &v : c)
v += luma - luma_now;
}
if (sat != 1.f) {
// Rec. 709 luma, the same weights the rest of the colour path uses to decide
// what counts as light and dark.
const float luma = LW[0] * c[0] + LW[1] * c[1] + LW[2] * c[2];
for (float &v : c)
v = luma + (v - luma) * sat;
}
for (int k = 0; k < 3; ++k) {
float v = std::clamp(c[k], 0.f, 1.f);
if (negate)
v = 1.f - v;
px[k] = uint8_t(std::lround(v * 255.f));
}
}
}
});
// Sharpen, threshold and the background key are all passes over the whole image rather than
// per-texel arithmetic, so they come after the loop above and in this order: sharpening a
// thresholded image would only ring along the one edge it has left, and the key has to read the
// colours the user is actually looking at.
sharpen_rgb(tex, layer.sharpness);
reduce_colors(tex, layer.color_count);
remove_background(tex, layer);
}
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer)
{
DecodedHeightTexture result;
@@ -335,6 +615,10 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
result.height = h;
result.pixels.resize(size_t(w) * size_t(h));
result.rgb.resize(size_t(w) * size_t(h) * 3);
// A fourth channel is coverage, and it is the only thing that can say "the picture is
// not here" as opposed to "the picture is black here". Kept only when the source has it.
if (bpp >= 4)
result.alpha.resize(size_t(w) * size_t(h));
// decode_colored_png() hands its buffer back bottom-up - it is shared with the CLI's
// plate-thumbnail loader, which expects that - while this type, and decode_png()'s
// grayscale path above, are top-to-bottom. Reverse the rows on the way in so a colour
@@ -348,6 +632,8 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
result.rgb[dst * 3 + 1] = g;
result.rgb[dst * 3 + 2] = b;
result.pixels[dst] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b));
if (bpp >= 4)
result.alpha[dst] = src[3];
}
}
@@ -1897,6 +2183,61 @@ std::vector<TextureIsland> compute_connected_net(const PatchUnwrap &unwrap)
return islands;
}
std::vector<uint8_t> transfer_facet_paint(const indexed_triangle_set &src_mesh,
const TriangleSelector::TriangleSplittingData &src_paint,
const indexed_triangle_set &dst_mesh)
{
if (src_mesh.indices.empty() || dst_mesh.indices.empty() || src_paint.bitstream.empty())
return {};
const TriangleMesh src(src_mesh);
TriangleSelector selector(src);
selector.deserialize(src_paint, false);
// Every sub-triangle of the source, each carrying its own state - NONE included, so the soup
// covers the whole surface and a foot point always lands on something. One soup rather than one
// tree per state: a foot point has one nearest triangle, and that triangle's state is the answer.
std::vector<indexed_triangle_set> per_state;
selector.get_facets(per_state);
indexed_triangle_set soup;
std::vector<uint8_t> label;
for (size_t st = 0; st < per_state.size(); ++st) {
const indexed_triangle_set &part = per_state[st];
if (part.indices.empty())
continue;
const int base = int(soup.vertices.size());
soup.vertices.insert(soup.vertices.end(), part.vertices.begin(), part.vertices.end());
for (const stl_triangle_vertex_indices &t : part.indices) {
soup.indices.emplace_back(t[0] + base, t[1] + base, t[2] + base);
label.push_back(uint8_t(st));
}
}
if (soup.indices.empty())
return {};
const AABBTreeIndirect::Tree3f tree =
AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(soup.vertices, soup.indices);
std::vector<uint8_t> out(dst_mesh.indices.size(), 0);
tbb::parallel_for(tbb::blocked_range<size_t>(0, dst_mesh.indices.size()),
[&](const tbb::blocked_range<size_t> &range) {
for (size_t j = range.begin(); j < range.end(); ++j) {
const stl_triangle_vertex_indices &t = dst_mesh.indices[j];
// The centroid, not a corner: a facet takes one state, and its middle is the point that
// is inside it whatever the triangle looks like.
const Vec3f centroid = (dst_mesh.vertices[size_t(t[0])] + dst_mesh.vertices[size_t(t[1])] +
dst_mesh.vertices[size_t(t[2])]) / 3.f;
size_t hit = 0;
Vec3f foot;
const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(soup.vertices, soup.indices,
tree, centroid, hit, foot);
if (d2 >= 0.f && hit < label.size())
out[j] = label[hit];
}
});
return out;
}
void average_island_scales(std::vector<TextureIsland> &islands)
{
if (islands.empty())
@@ -1999,6 +2340,110 @@ std::vector<Vec2f> compute_lscm_corner_uvs(const indexed_triangle_set &patch, co
return corner;
}
// Barycentric weights of `p` inside triangle (a, b, c), as the pair that belongs with the edges
// (b - a) and (c - a): p ~= (1 - w1 - w2) * a + w1 * b + w2 * c. `p` is projected onto the triangle's
// plane, so a point a hair off the surface still reports the weights of its foot point. A degenerate
// triangle reports its centroid, the only coordinate that means anything there.
static void barycentric_weights(const Vec3f &a, const Vec3f &b, const Vec3f &c, const Vec3f &p, float &w1, float &w2)
{
const Vec3f e0 = b - a, e1 = c - a, ep = p - a;
const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1);
const float den = d00 * d11 - d01 * d01;
if (std::abs(den) <= 1e-20f) {
w1 = w2 = 1.f / 3.f;
return;
}
w1 = (d11 * dp0 - d01 * dp1) / den;
w2 = (d00 * dp1 - d01 * dp0) / den;
}
std::vector<Vec2f> compute_model_corner_uvs(const indexed_triangle_set &base_mesh,
const std::vector<Vec2f> &model_uv,
const indexed_triangle_set &patch,
const std::vector<int> &patch_source)
{
if (patch.indices.empty() || model_uv.size() != base_mesh.indices.size() * 3 ||
patch_source.size() != patch.indices.size())
return {};
std::vector<Vec2f> corner(patch.indices.size() * 3, Vec2f::Zero());
for (size_t j = 0; j < patch.indices.size(); ++j) {
const int S = patch_source[j];
if (S < 0 || size_t(S) >= base_mesh.indices.size())
continue; // no source triangle: leave (0, 0), which the callers read as "no placement"
const stl_triangle_vertex_indices &src = base_mesh.indices[size_t(S)];
const Vec3f &a = base_mesh.vertices[size_t(src[0])];
const Vec3f &b = base_mesh.vertices[size_t(src[1])];
const Vec3f &c = base_mesh.vertices[size_t(src[2])];
const Vec2f *uv = &model_uv[size_t(S) * 3];
const stl_triangle_vertex_indices &tri = patch.indices[j];
for (int k = 0; k < 3; ++k) {
// An unsplit patch triangle is its source triangle, so its corners land exactly on a, b
// and c and this reproduces their uvs unchanged; a sub-triangle the brush cut gets the
// interpolated coordinate, which is the whole point of going through the barycentric.
float w1 = 0.f, w2 = 0.f;
barycentric_weights(a, b, c, patch.vertices[size_t(tri[k])], w1, w2);
corner[j * 3 + size_t(k)] = (1.f - w1 - w2) * uv[0] + w1 * uv[1] + w2 * uv[2];
}
}
return corner;
}
std::vector<Vec2f> collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector<Vec2f> &corner)
{
if (patch.indices.empty() || corner.size() != patch.indices.size() * 3)
return {};
std::vector<Vec2f> per_vertex(patch.vertices.size(), Vec2f::Zero());
std::vector<char> seen(patch.vertices.size(), 0);
for (size_t j = 0; j < patch.indices.size(); ++j)
for (int k = 0; k < 3; ++k) {
const int vi = patch.indices[j][k];
if (vi < 0 || size_t(vi) >= per_vertex.size() || seen[size_t(vi)])
continue;
seen[size_t(vi)] = 1;
per_vertex[size_t(vi)] = corner[j * 3 + size_t(k)];
}
return per_vertex;
}
std::vector<Vec2f> transfer_uv_channel(const indexed_triangle_set &src_mesh,
const std::vector<Vec2f> &src_uv,
const indexed_triangle_set &dst_mesh,
float max_distance)
{
if (src_mesh.indices.empty() || dst_mesh.indices.empty() || src_uv.size() != src_mesh.indices.size() * 3)
return {};
const AABBTreeIndirect::Tree3f tree =
AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(src_mesh.vertices, src_mesh.indices);
const float limit2 = max_distance >= 0.f ? max_distance * max_distance : std::numeric_limits<float>::max();
std::vector<Vec2f> out(dst_mesh.indices.size() * 3, Vec2f::Zero());
tbb::parallel_for(tbb::blocked_range<size_t>(0, dst_mesh.indices.size()),
[&](const tbb::blocked_range<size_t> &range) {
for (size_t j = range.begin(); j < range.end(); ++j) {
const stl_triangle_vertex_indices &tri = dst_mesh.indices[j];
for (int k = 0; k < 3; ++k) {
const Vec3f &p = dst_mesh.vertices[size_t(tri[k])];
size_t hit = 0;
Vec3f foot;
const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(
src_mesh.vertices, src_mesh.indices, tree, p, hit, foot);
if (d2 < 0.f || d2 > limit2 || hit >= src_mesh.indices.size())
continue; // nowhere near the source surface: (0, 0), as documented
const stl_triangle_vertex_indices &st = src_mesh.indices[hit];
float w1 = 0.f, w2 = 0.f;
barycentric_weights(src_mesh.vertices[size_t(st[0])], src_mesh.vertices[size_t(st[1])],
src_mesh.vertices[size_t(st[2])], foot, w1, w2);
const Vec2f *uv = &src_uv[hit * 3];
out[j * 3 + size_t(k)] = (1.f - w1 - w2) * uv[0] + w1 * uv[1] + w2 * uv[2];
}
}
});
return out;
}
namespace {
// apply_uv_transform()'s per-layer constants, worked out once. Triplanar sampling runs the transform
// three times per point, and recomputing the rotation's cos/sin and the tiling reciprocal on every one
@@ -2008,13 +2453,16 @@ struct UVTransform
float scale, cs, sn, aspect;
Vec2f offset;
UVTransform(const TextureDisplacementLayer &layer, float aspect_)
// `normalized_uv` drops the two steps that only mean something for a coordinate measured on the
// model - the millimetre tiling scale and the non-square aspect correction - and keeps the
// rotation and offset. See apply_uv_transform(); TextureProjectionMethod::ModelUV is what needs it.
UVTransform(const TextureDisplacementLayer &layer, float aspect_, bool normalized_uv = false)
{
scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f;
scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
cs = std::cos(rad);
sn = std::sin(rad);
aspect = aspect_;
aspect = normalized_uv ? 1.f : aspect_;
offset = layer.offset;
}
Vec2f operator()(const Vec2f &planar) const
@@ -2039,9 +2487,11 @@ inline Vec3f triplanar_weights(const Vec3f &normal)
}
} // namespace
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect)
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect, bool normalized_uv)
{
const float scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f;
// A uv that is already normalized has no millimetre scale to divide by and no aspect to correct -
// see the header. UVTransform does the same, and the two must stay in step.
const float scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
const Vec2f scaled = planar * scale;
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
@@ -2059,7 +2509,7 @@ Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &la
// Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a
// shear, and doing it the other way round would make "Rotation" skew the pattern instead of
// turning it.
if (aspect > 0.f && aspect != 1.f)
if (!normalized_uv && aspect > 0.f && aspect != 1.f)
rotated.y() *= aspect;
return rotated + layer.offset;
@@ -2084,6 +2534,9 @@ float blend_displacement(float accumulated, float value, TextureBlendMode mode)
value;
return accumulated / divisor;
}
// Replaces instead of combining, so `accumulated` is deliberately unused: whatever the layers
// below put here is gone where this one is painted.
case TextureBlendMode::Override: return value;
case TextureBlendMode::Add:
default: return accumulated + value;
}
@@ -2113,14 +2566,21 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
// apply_uv_transform()). Every projection except the projective "from view" one funnels through
// here, so this one line is what makes them all aspect-correct.
const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f;
const UVTransform xf(layer, aspect);
// A ModelUV layer's precomputed uv is the model's own, already normalized; every other coordinate
// reaching the transform - the unwrap's, and every analytic projection's - is in millimetres. The
// `lscm_uv` test is what keeps the two apart: a ModelUV layer with no channel gets none, falls
// through to the millimetre methods below, and must be scaled like them.
const bool normalized_uv = layer.projection_method == TextureProjectionMethod::ModelUV &&
lscm_uv != nullptr;
const UVTransform xf(layer, aspect, normalized_uv);
auto sample_at = [&](const Vec2f &planar) {
return texture.sample(xf(planar), layer.tile_enabled, layer.tile_method);
};
// Precomputed per-patch LSCM solve wins over the layer's own method (see the header): the
// caller only passes it when the patch actually parameterized successfully, so a patch that
// failed to unwrap falls through to the analytic methods below as its documented fallback.
// A precomputed per-patch uv - the LSCM solve, or the model's own channel - wins over the layer's
// own method (see the header): the caller only passes it when the patch actually has one, so a
// patch that failed to unwrap, or a volume with no UV channel, falls through to the analytic
// methods below as its documented fallback.
if (lscm_uv != nullptr)
return sample_at(*lscm_uv);
@@ -2181,21 +2641,31 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
// the two differ in what "nothing here" means. Height returns 0, which is a perfectly good height
// (no displacement); colour has no such neutral value - black is a colour - so every path that
// returns 0 there has to report false here instead, and the caller leaves the triangle uncoloured.
const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f;
const UVTransform xf(layer, aspect);
const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f;
// Same reasoning as sample_layer_height(), and it has to match it exactly or a layer's colour and
// its relief would be read from different places in the image.
const bool normalized_uv = layer.projection_method == TextureProjectionMethod::ModelUV &&
lscm_uv != nullptr;
const UVTransform xf(layer, aspect, normalized_uv);
auto sample_at = [&](const Vec2f &planar) {
return texture.sample_color(xf(planar), layer.tile_enabled, layer.tile_method);
};
// Outside a non-tiled placement there is no texture at all - the same hard edge sample() gives the
// height. Checked explicitly because sample_color() reports it as black, which is a real colour.
auto covered = [&](const Vec2f &planar) {
if (layer.tile_enabled)
return true;
const Vec2f uv = xf(planar);
return uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f;
if (!layer.tile_enabled && !(uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f))
return false;
// Where the picture is transparent there is no colour to print, which is the same answer as
// being outside the placement - not black, which is a colour. Half coverage counts as
// covered: a triangle prints in one filament either way, so the only useful question is
// which side of the edge it falls on.
return texture.sample_alpha(uv, layer.tile_enabled, layer.tile_method) >= 0.5f;
};
if (lscm_uv != nullptr) {
// The coverage test goes through the same transform, so an untiled ModelUV layer is "there"
// exactly where the model's own uv lands inside the image.
if (!covered(*lscm_uv))
return false;
out = sample_at(*lscm_uv);
@@ -2541,9 +3011,10 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
const TextureColorRequest *color,
bool flip_normals,
BakeStageRecorder *debug,
TextureBakeStats *stats)
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
{
HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data);
HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data, model_uv);
if (!combined)
return mesh; // nothing decodable to displace with
@@ -2713,7 +3184,8 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
// part of it (half a tile in the neighbour's colour, a tile edge that wanders).
const bool want_color = color != nullptr && color->out_triangle != nullptr && bool(color->quantize);
const ColorFieldSampler color_sampler =
want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure) : ColorFieldSampler{};
want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure, model_uv) :
ColorFieldSampler{};
//
// The *palette* index, not the printed filament. The decimation treats any edge whose two faces
// differ as a crease (TextureBakeDecimate.cpp), so it must only ever see where the **perceived**
@@ -2845,7 +3317,8 @@ static indexed_triangle_set build_texture_displacement_in_place(
const TextureColorRequest *color,
bool flip_normals,
BakeStageRecorder *debug,
TextureBakeStats *stats)
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
{
// The classic path moves the vertices the mesh already has, so there is no budget to report on.
(void) stats;
@@ -2882,7 +3355,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
if (options.pipeline_v2)
return build_texture_displacement_v2(mesh, layers, facets_data, options, progress, color, flip_normals,
debug, stats);
debug, stats, model_uv);
// Layers are combined in slot order, like stacked layers in an image editor: each one folds its
// own displacement into the running total via its blend mode (see TextureBlendMode).
@@ -3000,13 +3473,19 @@ static indexed_triangle_set build_texture_displacement_in_place(
selector_dirty = true;
const bool color_this_layer = want_color && layer->color_enabled;
// The model's own channel is per base-mesh *triangle*, so reading it needs to know which base
// triangle each (possibly brush-split) patch triangle came from - the colour pass's map, asked
// for here as well.
const bool model_uv_layer = layer->projection_method == TextureProjectionMethod::ModelUV &&
model_uv != nullptr && model_uv->size() == mesh.indices.size() * 3;
// A flat-colour image is matched against the filaments alone (see TextureColorRequest).
const ColorQuantizeFn &layer_quantize =
(color_this_layer && color->quantize_pure && analyze_texture_detail(*layer).flat_colors) ? color->quantize_pure
: color->quantize;
std::vector<int> patch_source; // sub-triangle -> base mesh triangle, only built when colouring
std::vector<int> patch_source; // sub-triangle -> base mesh triangle; built when colouring or mapping by ModelUV
const indexed_triangle_set patch =
selector.get_facets_strict(EnforcerBlockerType::ENFORCER, color_this_layer ? &patch_source : nullptr);
selector.get_facets_strict(EnforcerBlockerType::ENFORCER,
(color_this_layer || model_uv_layer) ? &patch_source : nullptr);
if (patch.indices.empty())
continue;
// get_facets_strict() returns the same vertex array whichever state is asked for (only the
@@ -3037,18 +3516,25 @@ static indexed_triangle_set build_texture_displacement_in_place(
// A real unwrap of the whole patch, computed once here rather than per vertex - it is a
// per-chart solve over the whole patch, not a per-point formula. Cached, so repeating this
// for every slider tweak costs a hash rather than a re-solve (see compute_patch_unwrap()).
const std::vector<Vec2f> lscm_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ?
// ModelUV takes its corners from the volume's own channel instead of solving anything, and
// collapses them to one per vertex the same way the unwrap does.
const std::vector<Vec2f> model_corner_uvs = model_uv_layer ?
compute_model_corner_uvs(mesh, *model_uv, patch, patch_source) :
std::vector<Vec2f>{};
const std::vector<Vec2f> patch_uvs = model_uv_layer ? collapse_corner_uvs(patch, model_corner_uvs) :
(layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_uvs(patch, *layer) :
std::vector<Vec2f>{};
// The colour pass below samples per *triangle*, so it takes the per-corner unwrap instead: the
// per-vertex collapse above would hand a triangle at a seam the island layout did not join its
// neighbour's placement, painting one triangle per face from the wrong part of the texture.
// (The displacement itself stays on lscm_uvs - a vertex has one position, so one height.)
const std::vector<Vec2f> lscm_corner_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ?
// (The displacement itself stays on patch_uvs - a vertex has one position, so one height.)
const std::vector<Vec2f> patch_corner_uvs = model_uv_layer ? model_corner_uvs :
(layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_corner_uvs(patch, *layer) :
std::vector<Vec2f>{};
const bool corner_uv_ok = !lscm_corner_uvs.empty() &&
lscm_corner_uvs.size() == patch.indices.size() * 3;
const bool corner_uv_ok = !patch_corner_uvs.empty() &&
patch_corner_uvs.size() == patch.indices.size() * 3;
// Colour, if this layer carries any. Area-weighted over each base triangle's *painted* part,
// so a triangle the brush only clipped a corner off takes the colour of that corner rather
@@ -3078,7 +3564,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
// the per-vertex height sampling uses, evaluated at the centroid instead.
Vec3f n = Vec3f::Zero();
Vec2f uv = Vec2f::Zero();
bool have_uv = !lscm_uvs.empty();
bool have_uv = !patch_uvs.empty();
for (int k = 0; k < 3; ++k) {
const int vi = t[k];
if (vi < int(vertex_normals.size()))
@@ -3086,9 +3572,9 @@ static indexed_triangle_set build_texture_displacement_in_place(
if (!have_uv)
continue;
if (corner_uv_ok)
uv += lscm_corner_uvs[j * 3 + size_t(k)];
else if (size_t(vi) < lscm_uvs.size())
uv += lscm_uvs[size_t(vi)];
uv += patch_corner_uvs[j * 3 + size_t(k)];
else if (size_t(vi) < patch_uvs.size())
uv += patch_uvs[size_t(vi)];
else
have_uv = false;
}
@@ -3162,7 +3648,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
[&](const tbb::blocked_range<size_t> &range) {
for (size_t k = range.begin(); k < range.end(); ++k) {
const size_t vi = size_t(layer_vertices[k]);
const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_uvs[vi];
const Vec2f *lscm_uv = patch_uvs.empty() ? nullptr : &patch_uvs[vi];
const float h = sample_layer_height(height, *layer, mesh.vertices[vi], vertex_normals[vi],
patch_centroid, patch_axis, lscm_uv);
// midlevel is the height that means "stay put", so anything below it displaces
@@ -3295,7 +3781,8 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const TextureColorRequest *color,
const Transform3d &volume_to_world,
BakeStageRecorder *debug,
TextureBakeStats *stats)
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
{
// An untransformed volume on an untransformed instance is by far the common case, and the round
// trip costs two matrix multiplies per vertex on a mesh that can carry millions of them - so take
@@ -3305,7 +3792,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const Transform3d frame = texture_displacement_bake_frame(volume_to_world);
if (frame.matrix().isApprox(Transform3d::Identity().matrix()))
return build_texture_displacement_in_place(base_mesh, layers, facets_data, options, progress, color,
false, debug, stats);
false, debug, stats, model_uv);
const Transform3d to_local = frame.inverse();
// A mirroring placement leaves the positions correct but every winding-derived normal pointing
@@ -3319,8 +3806,10 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
v = (frame * v.cast<double>()).cast<float>();
const size_t debug_mark = (debug != nullptr) ? debug->mark() : 0;
// The UV channel rides along untouched: a uv is a coordinate in the texture, not on the model, so
// the frame the mesh is measured in does not reach it.
indexed_triangle_set out = build_texture_displacement_in_place(world, layers, facets_data, options,
progress, color, mirrored, debug, stats);
progress, color, mirrored, debug, stats, model_uv);
// Everything the bake recorded is in world millimetres, like `out` itself. The debug view draws in
// the volume's local frame, so the stages are brought back the same way the result is.
if (debug != nullptr)
@@ -3392,7 +3881,8 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume)
return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data,
volume.texture_displacement_options, {}, nullptr,
texture_displacement_volume_to_world(volume));
texture_displacement_volume_to_world(volume), nullptr, nullptr,
volume.has_texture_uv() ? &volume.texture_uv : nullptr);
}
void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t> &movable, float strength,
@@ -3456,16 +3946,18 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t>
namespace {
// One decoded texture + placement per sampleable layer, in blend (slot) order. Held by shared_ptr so
// the returned closure owns it for as long as the subdivider keeps calling back.
// An unwrap turned into something a *point* sampler can use. LSCM has no formula from position to
// uv - it is a per-triangle map - so a point is placed on the painted patch (the nearest patch
// triangle, and its barycentric coordinates there) and the uv is interpolated from that triangle's own
// per-corner uvs. Exact for a point on the base surface, which is where both samplers are queried: the
// displacement samples refined positions before moving them, and the colour pass samples the foot
// point on the painted pieces.
// A per-triangle uv map turned into something a *point* sampler can use. Neither of the two maps that
// are not a formula from position to uv - the LSCM unwrap and the model's own UV channel - can be
// evaluated at an arbitrary point directly, so a point is placed on the painted patch (the nearest
// patch triangle, and its barycentric coordinates there) and the uv is interpolated from that
// triangle's own per-corner uvs. Exact for a point on the base surface, which is where both samplers
// are queried: the displacement samples refined positions before moving them, and the colour pass
// samples the foot point on the painted pieces.
struct LscmLookup {
indexed_triangle_set patch; // the layer's painted patch, as the unwrap was solved on
indexed_triangle_set patch; // the layer's painted patch, as the uvs were computed on
AABBTreeIndirect::Tree3f tree;
std::vector<Vec2f> corner; // compute_lscm_corner_uvs(patch, layer)
// compute_lscm_corner_uvs(patch, layer), or compute_model_corner_uvs() for a ModelUV layer
std::vector<Vec2f> corner;
// False when `pos` is not on this layer's patch (farther than `tol`): there is no uv there, so the
// layer contributes nothing - the same as a non-tiled texture outside its placement.
@@ -3478,15 +3970,9 @@ struct LscmLookup {
if (d2 < 0.f || d2 > tol * tol || hit >= patch.indices.size())
return false;
const stl_triangle_vertex_indices &t = patch.indices[hit];
const Vec3f &a = patch.vertices[size_t(t[0])], &b = patch.vertices[size_t(t[1])], &c = patch.vertices[size_t(t[2])];
const Vec3f e0 = b - a, e1 = c - a, ep = foot - a;
const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1);
const float den = d00 * d11 - d01 * d01;
float w1 = 1.f / 3.f, w2 = 1.f / 3.f; // a degenerate triangle takes its centroid's uv
if (std::abs(den) > 1e-20f) {
w1 = (d11 * dp0 - d01 * dp1) / den;
w2 = (d00 * dp1 - d01 * dp0) / den;
}
float w1 = 0.f, w2 = 0.f; // a degenerate triangle takes its centroid's uv
barycentric_weights(patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])], patch.vertices[size_t(t[2])],
foot, w1, w2);
const Vec2f *c3 = &corner[hit * 3];
uv = (1.f - w1 - w2) * c3[0] + w1 * c3[1] + w2 * c3[2];
return true;
@@ -3545,7 +4031,7 @@ struct PreparedLayer {
// where a layer is placed. `need_color` additionally drops layers that cannot contribute colour.
std::shared_ptr<std::vector<PreparedLayer>> prepare_sampleable_layers(
const indexed_triangle_set &base_mesh, const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data, bool need_color)
const TextureDisplacementFacetsData &facets_data, bool need_color, const std::vector<Vec2f> *model_uv)
{
auto prepared = std::make_shared<std::vector<PreparedLayer>>();
@@ -3572,13 +4058,22 @@ std::shared_ptr<std::vector<PreparedLayer>> prepare_sampleable_layers(
const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)];
if (data.triangles_to_split.empty())
continue;
const DecodedHeightTexture tex = decode_height_texture(*layer);
DecodedHeightTexture tex = decode_height_texture(*layer);
if (tex.empty() || (need_color && !tex.has_color()))
continue;
// The one place the bake and every preview job read a layer's colour from, so correcting it
// here is what keeps what the panel shows and what the bake prints the same image.
if (need_color)
apply_color_adjustments(tex, *layer);
const bool model_uv_layer = layer->projection_method == TextureProjectionMethod::ModelUV &&
model_uv != nullptr && model_uv->size() == base_mesh.indices.size() * 3;
TriangleSelector selector(selector_mesh);
selector.deserialize(data, false);
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER);
std::vector<int> patch_source; // only the model's own channel needs it; see the classic path
const indexed_triangle_set patch =
selector.get_facets_strict(EnforcerBlockerType::ENFORCER, model_uv_layer ? &patch_source : nullptr);
if (patch.indices.empty())
continue;
@@ -3588,12 +4083,16 @@ std::shared_ptr<std::vector<PreparedLayer>> prepare_sampleable_layers(
texture_displacement_patch_frame(patch, vertex_normals, centroid, axis, average_normal);
std::shared_ptr<const LscmLookup> lscm;
if (layer->projection_method == TextureProjectionMethod::LSCM) {
if (model_uv_layer || layer->projection_method == TextureProjectionMethod::LSCM) {
// Solved on the very patch the classic path and the GUI solve it on (same geometry, seam
// angle and edges), so it hits the unwrap cache and lands exactly where the UV editor
// shows it, hand-placed islands and UV edits included.
// shows it, hand-placed islands and UV edits included. A ModelUV layer needs no solve at
// all - its corners are read straight out of the volume's channel - but it is looked up
// through the same per-triangle machinery, for the same reason: there is no formula from a
// point to its uv.
auto l = std::make_shared<LscmLookup>();
l->corner = compute_lscm_corner_uvs(patch, *layer);
l->corner = model_uv_layer ? compute_model_corner_uvs(base_mesh, *model_uv, patch, patch_source) :
compute_lscm_corner_uvs(patch, *layer);
if (l->corner.size() == patch.indices.size() * 3) {
l->patch = patch;
l->tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(l->patch.vertices, l->patch.indices);
@@ -3619,11 +4118,12 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize,
ColorQuantizeFn quantize_pure)
ColorQuantizeFn quantize_pure,
const std::vector<Vec2f> *model_uv)
{
if (!quantize)
return nullptr;
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true);
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true, model_uv);
if (prepared->empty())
return nullptr;
// Per layer: a flat-colour image is matched against the filaments alone, when that quantizer
@@ -3654,9 +4154,10 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data)
const TextureDisplacementFacetsData &facets_data,
const std::vector<Vec2f> *model_uv)
{
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false);
auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false, model_uv);
if (prepared->empty())
return nullptr;
+166 -6
View File
@@ -86,6 +86,17 @@ enum class TextureProjectionMethod : int
// that is inherent to view projection and is the user's call, not a bug. The projector's two
// in-plane axes live in TextureDisplacementLayer::view_project_right/up.
ViewProjected = 4,
// The model's own texture coordinates, as the file it was imported from carried them (see
// ModelVolume::texture_uv). Nothing is unwrapped or projected: the artist's layout *is* the
// placement, so a texture authored against that layout - the model's own albedo map, say - lands
// exactly where it was painted. Automatically picked for a new layer on a volume that has a UV
// channel, since re-unwrapping a model that already carries one can only be worse than using it.
//
// The uv is already normalized, so unlike every method above it is not measured in millimetres:
// "Tile size" and the non-square aspect correction do not apply to it (the layer's rotation and
// offset still do). Falls back to Triplanar when the volume has no channel, exactly as LSCM falls
// back when a patch cannot be flattened.
ModelUV = 5,
};
// Dihedral angle (degrees) above which an edge between two painted triangles becomes a chart seam
@@ -121,6 +132,11 @@ enum class TextureBlendMode : int
Subtract = 1, // acc - value (carve this layer's relief out of the layers below)
Multiply = 2, // acc * (value / 1mm) (mask/modulate the layers below by this layer)
Divide = 3, // acc / (value / 1mm) (inverse mask; guarded against a zero/near-zero divisor)
// acc = value: this layer replaces what is under it instead of combining with it, so where it is
// painted only its own relief remains. The image editor's "Normal" mode, under the name the panel
// uses. Note the stack's *colour* already behaves this way - a higher slot's colour overwrites a
// lower one's where they overlap - so this is what makes the displacement agree with it.
Override = 4,
};
// Combines one layer's signed displacement `value` (mm) into `accumulated` (mm) per `mode`.
@@ -337,6 +353,32 @@ struct TextureDisplacementLayer
// for the boundary to be drawn on.
bool color_enabled = false;
// Colour correction, applied to the image's RGB before it is matched against the filaments and
// before the preview quantizes it. Never to the height: the relief is what the grayscale says,
// and a brightness slider has no business moving geometry. The defaults below are the identity,
// so a layer that never meets the Image tool behaves exactly as it did.
float brightness = 0.f; // -1 .. 1, added last, after contrast
float contrast = 0.f; // -1 .. 1, pivoted on mid grey; -1 flattens to grey, 1 doubles the slope
float hue = 0.f; // -180 .. 180 degrees, rotated about the grey axis at constant luma
float sharpness = 0.f; // 0 .. 1 unsharp mask amount, 0 is off
// How many colours the picture is allowed to use, 0 for as many as it likes. Reduced by k-means
// in RGB before the filaments are matched, so the result uses at most this many of them: 2 for a
// logo that should be two flat colours, 4 or 5 for a poster, off for a photograph.
int color_count = 0; // 0, or 2 .. 12
// Leaves the background of a picture unprinted, so the object's own colour shows through instead
// of a slab of white behind a logo. Texels the source image marked transparent always count;
// `bg_remove` additionally keys out one colour.
bool bg_remove = false;
float bg_tolerance = 0.15f; // 0 .. 1, distance from the keyed colour that still counts as background
// The colour keyed out. Taken from the median of the picture's four corners while
// `bg_from_corners` is set, which is right for nearly every logo on a flat card; the panel's
// dropper and swatch clear it and put a colour of the user's own here.
bool bg_from_corners = true;
float bg_color[3] = { 1.f, 1.f, 1.f };
float saturation = 1.f; // 0 .. 2, 1 leaves it alone, 0 is grayscale
float gamma = 1.f; // 0.2 .. 5, 1 leaves it alone; above 1 lifts the midtones
bool color_invert = false; // negative of the colour, as opposed to `invert` which flips relief
bool empty() const { return !image_data || image_data->empty(); }
template<class Archive> void save(Archive &ar) const
@@ -346,7 +388,9 @@ struct TextureDisplacementLayer
static_cast<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
static_cast<int>(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right,
view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups,
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled);
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, brightness,
contrast, saturation, gamma, color_invert, hue, sharpness, color_count, bg_remove,
bg_tolerance, bg_from_corners, bg_color[0], bg_color[1], bg_color[2]);
}
template<class Archive> void load(Archive &ar)
{
@@ -358,7 +402,8 @@ struct TextureDisplacementLayer
tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel,
island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing,
edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective,
view_project_matrix, color_enabled);
view_project_matrix, color_enabled, brightness, contrast, saturation, gamma, color_invert, hue, sharpness,
color_count, bg_remove, bg_tolerance, bg_from_corners, bg_color[0], bg_color[1], bg_color[2]);
image_data = blob.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(blob.begin(), blob.end());
tile_method = static_cast<TextureTileMethod>(tile_method_int);
projection_method = static_cast<TextureProjectionMethod>(projection_method_int);
@@ -519,11 +564,19 @@ struct DecodedHeightTexture
// has_color() is what the whole colour feature keys off: a layer set to colour a model with a
// grayscale texture on it simply colours nothing.
std::vector<uint8_t> rgb;
// Coverage: 0 where the picture is not there at all, 255 where it is fully there. Empty when the
// source image had no alpha and nothing has keyed a background out, which is the common case and
// means "opaque everywhere". Distinct from a black texel, which is a colour.
std::vector<uint8_t> alpha;
int width = 0;
int height = 0;
bool empty() const { return width <= 0 || height <= 0 || pixels.empty(); }
bool has_color() const { return !empty() && rgb.size() == size_t(width) * size_t(height) * 3; }
bool has_alpha() const { return !empty() && alpha.size() == size_t(width) * size_t(height); }
// Coverage at a uv, bilinear, in [0, 1]. 1 everywhere when there is no alpha plane.
float sample_alpha(const Vec2f &uv, bool tile_enabled = true,
TextureTileMethod tile_method = TextureTileMethod::Repeat) const;
// Bilinearly sampled height in [0, 1] at a normalized uv coordinate. When tile_enabled is false,
// a uv outside [0, 1) samples as 0 - the texture simply is not there, rather than its border
// row/column being smeared outward forever (which is what clamping the coordinate would do, and
@@ -590,6 +643,19 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
// texture - cheap enough to ask every frame. Smoothing does not change it, so the raw decode is what is read.
bool height_texture_has_color(const TextureDisplacementLayer &layer);
// True when `layer`'s colour correction is anything other than the identity, so callers can skip
// both the work and the cache invalidation it would otherwise force.
bool has_color_adjustments(const TextureDisplacementLayer &layer);
// Applies that correction to an already decoded image, in place, to `rgb` only. Order is gamma,
// contrast, brightness, saturation, invert, each clamped back into range - the order a photo editor
// applies them in, and the one that keeps contrast pivoted on mid grey rather than on a brightness
// the slider has already moved.
//
// Deliberately not folded into decode_height_texture(): that result is cached per image, shared by
// every layer using the same file, and keyed on the image bytes alone.
void apply_color_adjustments(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer);
// Maps a linear RGB colour in [0, 1] to an index into the caller's palette, or -1 for "no colour".
//
// Deliberately a callback rather than a function here: matching a colour to a filament is a
@@ -655,7 +721,13 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// squeezed into a square tile: `tiling_scale` is the tile's size along u, and the tile is
// `tiling_scale * height / width` mm along v, which keeps texels square. 1 (the default) is the
// square case and leaves the coordinate exactly as it always was.
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f);
//
// `normalized_uv` says the coordinate is already a uv rather than a millimetre measurement on the
// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tiling scale (mm
// per tile is meaningless for it) nor the aspect correction (the artist's layout already accounts for
// the image's proportions) applies, and only the rotation and offset are left.
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f,
bool normalized_uv = false);
// Applies a row-major 3x4 projective matrix (see TextureDisplacementLayer::view_project_matrix) to a
// local-space point, writing the resulting texture uv. Returns false - and leaves `uv` untouched -
@@ -692,6 +764,31 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
const Vec3f &patch_center = Vec3f::Zero(), const Vec3f &patch_axis = Vec3f::UnitZ(),
const Vec2f *lscm_uv = nullptr);
// Carries a per-facet paint annotation - the colour paint above all - from a mesh onto the mesh a
// bake produced from it: every triangle of `dst_mesh` takes the state painted at its own foot point on
// `src_mesh`, i.e. on the nearest point of the surface it was built from. Returned as one
// EnforcerBlockerType per `dst_mesh` triangle, 0 (NONE) where that foot point carried no paint.
//
// ModelVolume::restore_painting() cannot do this. Its remap pairs a source paint triangle with a
// target triangle that *overlaps* it to within 0.01 mm, which is right for a remesh or a
// simplification - they leave the surface where it was - and useless after a bake, which moves the
// surface by the relief depth. Measured on a model displaced by a tenth of its own size, that remap
// kept a quarter of the colour and left the rest unpainted; this keeps essentially all of it, because
// a foot point is exactly as far away as the relief is deep and the lookup does not care how far that
// is.
//
// The source's *sub*-triangles are what is looked up, not its triangles, so the part of a triangle a
// brush stroke only clipped a corner off keeps its own state rather than rounding to the whole
// triangle's. No distance limit is needed or wanted: the nearest point on the source surface is where
// a baked triangle came from however far the relief carried it, and an unpainted origin reports NONE
// on its own.
//
// Returns an empty vector when `src_paint` is empty or either mesh is - the caller then has nothing to
// carry, which is not the same as "carry nothing".
std::vector<uint8_t> transfer_facet_paint(const indexed_triangle_set &src_mesh,
const TriangleSelector::TriangleSplittingData &src_paint,
const indexed_triangle_set &dst_mesh);
// Area-weighted centroid and average normal of a layer's currently painted patch, in mesh-local
// coordinates - the same measurements build_texture_displacement() uses to pick its dominant
// projection axis. Used by the GUI to anchor the on-canvas "adjust texture placement" gizmo to
@@ -805,6 +902,55 @@ std::vector<Vec2f> compute_lscm_uvs(const indexed_triangle_set &patch, const Tex
// Returns an empty vector if the patch has no triangles or the unwrap carries no source_face map.
std::vector<Vec2f> compute_lscm_corner_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer);
// Three UVs per patch triangle, read out of the model's *own* UV channel - the
// TextureProjectionMethod::ModelUV counterpart of compute_lscm_corner_uvs(), and laid out the same way
// (corner k of triangle i at index 3i+k).
//
// `model_uv` is the volume's channel: three uvs per triangle of `base_mesh`, in its triangle order
// (see ModelVolume::texture_uv). `patch_source` maps each patch triangle back to the base-mesh
// triangle it was cut from - TriangleSelector::get_facets_strict()'s out_source. A patch triangle is
// always a piece of exactly one base triangle, so each of its corners is interpolated barycentrically
// inside that triangle's three uvs. That is what keeps a sub-triangle the brush split (whose corners
// are not base-mesh vertices at all) on the texture, and what keeps the two sides of a UV seam
// reading from their own island instead of from one averaged coordinate.
//
// Returns an empty vector when the channel does not match `base_mesh`, when `patch_source` does not
// match `patch`, or when either is empty - the callers treat that as "no placement" and fall back,
// exactly as they do for an unwrap that failed.
std::vector<Vec2f> compute_model_corner_uvs(const indexed_triangle_set &base_mesh,
const std::vector<Vec2f> &model_uv,
const indexed_triangle_set &patch,
const std::vector<int> &patch_source);
// One UV per patch vertex, collapsed from a per-corner array: the first corner to reach a vertex wins.
// This is the per-corner/per-vertex split compute_lscm_uvs() documents, applied to any corner array -
// displacement is per-vertex, so a vertex on a seam has to settle on one of its copies.
// Returns an empty vector if `corner` is not three per patch triangle.
std::vector<Vec2f> collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector<Vec2f> &corner);
// Resamples a per-corner UV channel from `src_mesh` onto `dst_mesh`, for when something replaces a
// volume's mesh and the model's own UV channel has to come along: the colour import's painted mesh,
// the gizmo's remesh/subdivide, the default bake pipeline's rebuild. Each destination corner takes the
// uv of its closest point on the source surface, interpolated barycentrically inside the source
// triangle it lands on - the same transfer LscmLookup does per point, run over every corner.
//
// Exact wherever the two meshes describe the same surface, which is the case for every caller here
// (they subdivide, repair or displace it, they do not reshape it). A corner that lands nowhere near
// the source - farther than `max_distance` (negative: no limit) - takes (0, 0).
//
// One thing a positional transfer cannot reproduce: a *seam* vertex, where one position carries a
// different uv in each island that meets there. Position alone does not say which island a corner
// belongs to, so corners on a seam all take whichever island the nearest-triangle lookup lands on,
// and the texture can be off by up to one triangle in a band along the island borders. Doing better
// needs a triangle-to-triangle correspondence, which only some of the stages that replace a mesh
// have (a remesh has none at all).
//
// Returns an empty vector when `src_uv` is not three per source triangle, or when either mesh is empty.
std::vector<Vec2f> transfer_uv_channel(const indexed_triangle_set &src_mesh,
const std::vector<Vec2f> &src_uv,
const indexed_triangle_set &dst_mesh,
float max_distance = -1.f);
// The TextureDisplacementLayer::lscm_uv_overrides key for one unwrapped vertex (an index into PatchUnwrap::uvs).
inline int lscm_uv_override_key(int unwrapped_vertex) { return -(unwrapped_vertex + 1); }
@@ -863,6 +1009,10 @@ using TextureDisplacementFacetsData = std::array<TriangleSelector::TriangleSplit
//
// `color`, when given, also reports which filament each triangle should print in - see
// TextureColorRequest.
//
// `model_uv`, when given, is the volume's own UV channel (three uvs per base-mesh triangle, see
// ModelVolume::texture_uv) and is what a TextureProjectionMethod::ModelUV layer maps through. A layer
// set to ModelUV without it falls back to Triplanar.
struct TextureColorRequest
{
// RGB -> palette index. Supplied by the GUI, which owns both the perceptual matching and the list
@@ -937,7 +1087,9 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
// stage, already brought back into `base_mesh`'s frame.
BakeStageRecorder *debug = nullptr,
// When given, receives what the bake spent.
TextureBakeStats *stats = nullptr);
TextureBakeStats *stats = nullptr,
// The model's own UV channel; see above.
const std::vector<Vec2f> *model_uv = nullptr);
// `volume`'s mesh coordinates -> world millimetres: its first instance's transform times its own.
// The mesh is shared by every instance, so a multi-instance object can only be baked for one of
@@ -1012,11 +1164,15 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize,
ColorQuantizeFn quantize_pure = nullptr);
ColorQuantizeFn quantize_pure = nullptr,
// The model's own UV channel, for ModelUV layers; see
// build_texture_displacement().
const std::vector<Vec2f> *model_uv = nullptr);
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data);
const TextureDisplacementFacetsData &facets_data,
const std::vector<Vec2f> *model_uv = nullptr);
// Uniformly subdivides `mesh` (every triangle recursively split into 4 via edge midpoints, using a
// shared cache so a midpoint is computed once and reused by both triangles on either side of that
@@ -1221,6 +1377,10 @@ struct TextureDisplacementPrepareResult
{
indexed_triangle_set mesh;
TextureDisplacementFacetsData masks;
// The model's own UV channel, resampled onto `mesh` (see transfer_uv_channel()). Empty when the
// volume had none. Like the masks it is handed back rather than left to the caller to recompute,
// because only the run itself knows which stages replaced the mesh.
std::vector<Vec2f> uv;
// The remesh landed but no layer's paint survived being carried onto it. Nothing is committed:
// everything downstream is driven by that paint, so baking on would bake a flat mesh.
bool paint_lost = false;
+21
View File
@@ -25,6 +25,7 @@
#include "TextureToColor/ColorUtils.hpp"
#include "Model.hpp"
#include "TextureDisplacement.hpp"
#include "TextureToColor/TriMesh.hpp"
#include "TriangleMesh.hpp"
#include "TriangleSelector.hpp"
@@ -554,9 +555,29 @@ bool apply_painted_mesh_to_volume(
<< ", unit_scale=" << unit_scale << ")";
}
// The model's own UV channel is indexed by triangle, and this replaces the triangles, so it has
// to be resampled onto them. Kept rather than dropped because it is the layout the model was
// authored against: a texture-displacement layer set to ModelUV maps through it, and the colour
// import is exactly the path a model that carries one arrives by.
const indexed_triangle_set old_its = volume.has_texture_uv() ? volume.mesh().its : indexed_triangle_set{};
const std::vector<Vec2f> old_uv = volume.texture_uv;
volume.set_mesh(std::move(new_mesh));
volume.calculate_convex_hull();
// Resampled here and not further down, while the two meshes still share a frame: the painted mesh
// was just shifted by the same source.mesh_offset the old one was centred by, so the only thing
// between them is whatever drift the repair introduced. The re-centering below translates the mesh
// again, and a translation is exactly what a closest-point transfer cannot see through - it would
// read every uv off a point that far away. A uv is a coordinate in the texture, so it does not care
// about either shift; it just has to be read before the second one happens.
if (!old_its.indices.empty())
volume.texture_uv = transfer_uv_channel(old_its, old_uv, volume.mesh().its);
else
// No channel to carry, so make sure none is left behind either: a leftover of the right length
// would read as a valid channel against a mesh it was never measured on.
volume.reset_texture_uv();
// Re-center the replaced mesh so its bbox center sits at the origin,
// matching what center_geometry_after_creation did for the original mesh.
// CGAL repair / subdivision may shift the bbox center (drift); without
+2
View File
@@ -186,6 +186,8 @@ set(SLIC3R_GUI_SOURCES
GUI/Gizmos/GLGizmoFlatten.hpp
GUI/Gizmos/GLGizmoFuzzySkin.cpp
GUI/Gizmos/GLGizmoFuzzySkin.hpp
GUI/Gizmos/GLGizmoImage.cpp
GUI/Gizmos/GLGizmoImage.hpp
GUI/Gizmos/GLGizmoMeasure.cpp
GUI/Gizmos/GLGizmoMeasure.hpp
GUI/Gizmos/GLGizmoMeshBoolean.cpp
+891
View File
@@ -0,0 +1,891 @@
#include "GLGizmoImage.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/format.hpp"
#include "slic3r/GUI/Camera.hpp"
#include "slic3r/GUI/GLCanvas3D.hpp"
#include "slic3r/GUI/GLTexture.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/IconManager.hpp"
#include "slic3r/GUI/ImGuiWrapper.hpp"
#include "slic3r/GUI/Plater.hpp"
#include "slic3r/GUI/TextureLibrary.hpp"
#include "slic3r/GUI/Shortcuts.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoUtils.hpp"
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <imgui.h>
#include <optional>
#include <string>
#include <vector>
#include <wx/string.h>
namespace Slic3r::GUI {
GLGizmoImage::GLGizmoImage(GLCanvas3D &parent, const std::string &icon_filename, unsigned int sprite_id)
: GLGizmoTextureDisplacement(parent, icon_filename, sprite_id)
{
}
bool GLGizmoImage::on_init()
{
if (!GLGizmoTextureDisplacement::on_init())
return false;
// Everything the base set up stays - it is the same painting machinery - except the key that
// opens it, which is this tool's own.
m_shortcut = Shortcut::GizmoImage;
return true;
}
std::string GLGizmoImage::on_get_name() const
{
return _u8L("Image");
}
wxString GLGizmoImage::handle_snapshot_action_name(bool shift_down, GLGizmoPainterBase::Button button_down) const
{
// Same paint, same masks; only the undo step's wording follows the tool the user is in.
const bool erasing = shift_down || (m_erase_mode != (button_down == GLGizmoPainterBase::Button::Right));
return erasing ? _L("Remove image from the area") : _L("Place image");
}
bool GLGizmoTextureDisplacement::pick_background_at(const Vec2d &mouse_pos)
{
ModelVolume *mv = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
TextureDisplacementLayer *layer = mv != nullptr ? active_layer() : nullptr;
if (mv == nullptr || mo == nullptr || layer == nullptr || layer->empty())
return false;
const int idx = texture_volume_raycaster_index();
if (idx < 0)
return false;
const Selection &selection = m_parent.get_selection();
const Transform3d trafo = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
const Camera &camera = wxGetApp().plater()->get_camera();
Vec3f hit = Vec3f::Zero(), normal = Vec3f::Zero();
if (!m_c->raycaster()->raycasters()[size_t(idx)]->unproject_on_mesh(
mouse_pos, trafo, camera, hit, normal,
m_c->object_clipper()->get_clipping_plane()))
return false;
// The picture's own colour at that point, read through the projection the bake uses, rather
// than the pixel on screen: the screen pixel carries the lighting and the filament the colour
// was already quantized to, so keying on it would remove the wrong thing.
//
// Uncorrected, deliberately. The key is applied at the end of the correction chain, against
// colours the earlier passes have already moved, so it has to be stated in those terms - the
// same reason the panel's dropper reads the corrected preview.
const indexed_triangle_set &base = mv->mesh().its;
const indexed_triangle_set patch =
extract_painted_patch(base, mv->texture_displacement_facet(layer->slot).get_data());
Vec3f center = Vec3f::Zero(), axis = Vec3f::UnitZ(), average_normal = Vec3f::UnitZ();
if (!patch.indices.empty())
texture_displacement_patch_frame(patch, texture_displacement_vertex_normals(base), center, axis,
average_normal);
DecodedHeightTexture tex = decode_height_texture(*layer);
apply_color_adjustments(tex, *layer);
Vec3f rgb;
if (!sample_layer_color(tex, *layer, hit, normal, rgb, center, axis))
return false;
for (int c = 0; c < 3; ++c)
layer->bg_color[c] = std::clamp(rgb[c], 0.f, 1.f);
layer->bg_from_corners = false;
layer->bg_remove = true;
m_image_bg_picking = false;
m_preview_params_dirty = true;
m_parent.set_as_dirty();
return true;
}
void GLGizmoTextureDisplacement::set_file_drag_over(bool over)
{
if (!image_mode() || m_file_drag_over == over)
return;
m_file_drag_over = over;
// A drag is not a mouse move as far as the canvas is concerned, so nothing would repaint and the
// zone would appear only once something else happened to trigger a frame.
m_parent.set_as_dirty();
m_parent.request_extra_frame();
}
// Drawn over the whole canvas on the foreground list, from inside the panel's ImGui frame, which is
// what puts it above the 3D view and above the panel itself.
void GLGizmoTextureDisplacement::render_image_drop_hint()
{
if (!m_file_drag_over)
return;
const ImVec2 size = ImGui::GetIO().DisplaySize;
if (size.x < 1.f || size.y < 1.f)
return;
ImDrawList *dl = ImGui::GetForegroundDrawList();
const ImU32 teal = ImGui::GetColorU32(ImGuiWrapper::COL_ORCA);
// Dim the view so the zone reads as a state the canvas is in, not as decoration on top of it.
dl->AddRectFilled(ImVec2(0.f, 0.f), size, IM_COL32(0, 0, 0, 70));
const float inset = std::round(ImGui::GetFontSize() * 1.2f);
const ImVec2 a(inset, inset), b(size.x - inset, size.y - inset);
if (b.x <= a.x || b.y <= a.y)
return;
// A dashed border, drawn as runs along the four edges: ImGui 1.83 has no dashed stroke.
const float dash = std::round(ImGui::GetFontSize() * 0.75f);
const float gap = dash;
const float th = std::max(2.f, std::round(ImGui::GetFontSize() * 0.12f));
for (float x = a.x; x < b.x; x += dash + gap) {
const float x1 = std::min(x + dash, b.x);
dl->AddLine(ImVec2(x, a.y), ImVec2(x1, a.y), teal, th);
dl->AddLine(ImVec2(x, b.y), ImVec2(x1, b.y), teal, th);
}
for (float y = a.y; y < b.y; y += dash + gap) {
const float y1 = std::min(y + dash, b.y);
dl->AddLine(ImVec2(a.x, y), ImVec2(a.x, y1), teal, th);
dl->AddLine(ImVec2(b.x, y), ImVec2(b.x, y1), teal, th);
}
// What dropping here will do. Worded as an invitation rather than a promise: anything that is
// not a picture falls through to the model loader, exactly as it did before this tool existed.
const std::string text = _u8L("Drop a picture to put it on the model");
const ImVec2 ts = ImGui::CalcTextSize(text.c_str());
const float pad = std::round(ImGui::GetFontSize() * 0.8f);
const ImVec2 c((a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f);
const ImVec2 r0(c.x - ts.x * 0.5f - pad, c.y - ts.y * 0.5f - pad * 0.6f);
const ImVec2 r1(c.x + ts.x * 0.5f + pad, c.y + ts.y * 0.5f + pad * 0.6f);
dl->AddRectFilled(r0, r1, ImGui::GetColorU32(ImGuiCol_WindowBg, 0.92f), ImGui::GetStyle().FrameRounding);
dl->AddRect(r0, r1, teal, ImGui::GetStyle().FrameRounding, 0, th * 0.5f);
dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y - ts.y * 0.5f), ImGui::GetColorU32(ImGuiCol_Text), text.c_str());
}
bool GLGizmoTextureDisplacement::drop_image_file(const std::string &path)
{
if (!image_mode() || texture_volume() == nullptr)
return false;
// Imported rather than just read: the same thing the panel's Import tile does, so a dropped
// picture lands in "My images" and is there the next time without hunting for the file again.
std::string error;
const std::optional<TextureLibraryEntry> entry = import_texture_to_library(path, error);
if (!entry) {
show_error(nullptr, error);
return false;
}
// The tool starts with no layer at all, so a drop before anything is loaded has to make one.
if (active_layer() == nullptr)
add_texture_layer();
TextureDisplacementLayer *layer = active_layer();
if (layer == nullptr)
return false;
set_layer_texture(*layer, *entry);
m_parent.set_as_dirty();
return true;
}
// ---------------------------------------------------------------------------------------------
// The Image tool's window.
//
// A member of GLGizmoTextureDisplacement rather than of GLGizmoImage, and defined here rather than
// in that class's own file: it needs the same private machinery the displacement panel uses (the
// layer, the texture picker, the previews, the bake), and keeping it here leaves that 1700 line
// panel untouched instead of threading it with tests for which tool is drawing.
//
// What it deliberately leaves out, all of which only means something once the surface moves or once
// there is more than one picture: brushes and the erase toggle, the layer stack, blend modes, depth,
// midlevel, edge fade, tiling mode, the unwrap projection and its UV editor, seams, the wireframe
// and the mesh-preparation sections.
// ---------------------------------------------------------------------------------------------
void GLGizmoTextureDisplacement::render_image_input_window(float x, float y, float bottom_limit)
{
ModelObject *mo = m_c->selection_info()->model_object();
if (mo == nullptr)
return;
ModelVolume *mv = texture_volume();
// The palette follows project state nobody tells this gizmo about (the filaments in the
// sidebar), so it is checked once a frame and a change re-runs the preview.
cached_palette();
if (m_palette_changed)
m_preview_params_dirty = true;
// Everything this tool fixes, re-pinned each frame so what the preview shows is what Apply will
// produce: Standard's recipe, zero depth with colour on, a click that floods the connected
// surface rather than a brush, and the fast preview, which with no relief to build is both
// instant and exactly the final colour.
// Two statements, not one `||`: short-circuiting would skip the image presets on any frame the
// standard ones changed something.
const bool std_changed = apply_standard_mode_presets(mv);
const bool image_changed = apply_image_mode_presets(mv);
if (std_changed || image_changed)
m_preview_params_dirty = true;
m_tool_type = ToolType::SMART_FILL;
m_cursor_type = TriangleSelector::CursorType::POINTER;
m_erase_mode = false;
m_auto_update = true;
if (!m_use_shaded_preview)
apply_view_mode(1);
float scale = m_parent.get_scale();
#ifdef WIN32
const int dpi = get_dpi_for_window(wxGetApp().GetTopWindow());
scale *= (float) dpi / (float) DPI_DEFAULT;
#endif // WIN32
const float approx_height = m_imgui->scaled(22.f);
y = std::min(y, bottom_limit - approx_height);
// Docked (the default) it is pinned to the right edge of the 3D canvas and cannot be moved, the
// same place and the same rule as the displacement panel, so the two tools do not put their
// controls in different corners. Undocked it becomes an ordinary floating window with a title
// bar to drag it by; the position is deliberately not seeded on undock, so it stays exactly
// where it was and the user simply gains the ability to move it.
ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse;
if (!m_undocked) {
flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar;
// Right-aligned (pivot 1), so the width the window auto-resized to last frame does not need
// to be known here.
float right = float(m_parent.get_canvas_size().get_width()) - m_imgui->scaled(0.5f);
GizmoImguiSetNextWIndowPos(right, y, 0.f, 0.f, ImGuiCond_Always, 1.0f, 0.0f);
}
ImGuiWrapper::push_toolbar_style(m_parent.get_scale());
GizmoImguiBegin(get_name(), flags);
ensure_panel_icons();
const float previous_body_h = m_panel_body_h;
const float previous_footer_h = m_panel_footer_h;
const ImGuiStyle &style = ImGui::GetStyle();
const bool busy = m_bake_in_progress || m_prepare_in_progress;
const float panel_w = m_imgui->scaled(16.f);
const float frame_h = ImGui::GetFrameHeight();
const float icon_sm = std::round(frame_h * 1.05f);
const float gap_s = std::round(m_imgui->scaled(0.4f));
const float label_w = m_imgui->scaled(4.6f);
const float wrap_w = m_imgui->scaled(15.f);
const ImVec4 orca = ImGuiWrapper::COL_ORCA;
const ImVec4 col_frame = ImGui::GetStyleColorVec4(ImGuiCol_Separator);
const auto hover_tip = [&](const auto &text) {
if (ImGui::IsItemHovered())
m_imgui->tooltip(text, wrap_w);
};
// Label column plus a slider filling the rest of the row, the same shape the displacement panel
// uses, so the two tools read as one family.
const auto float_row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max,
const char *format, bool log) -> bool {
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
m_imgui->text(label);
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX());
return ImGui::SliderFloat(id, v, v_min, v_max, format,
ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0));
};
// Borderless icon button, for the dock toggle: always the grey monochrome variant.
const auto icon_button = [&](int uid, const std::string &iconfile, const wxString &label,
const wxString &tip_text) -> bool {
const auto it = m_panel_icon_map.find(iconfile);
const float pad = std::max(1.f, std::round(icon_sm * 0.14f));
bool clicked = false;
ImGui::PushID(uid);
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.f);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.f, 0.f, 0.f, 0.f));
if (it != m_panel_icon_map.end() && !it->second.empty() && it->second[0]->is_valid()) {
const IconManager::Icon &ic = *it->second[0];
clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic.tex_id,
ImVec2(icon_sm - 2.f * pad, icon_sm - 2.f * pad), ic.tl, ic.br, int(pad));
} else {
clicked = ImGui::Button(label.ToUTF8().data(), ImVec2(0.f, icon_sm));
}
ImGui::PopStyleColor();
ImGui::PopStyleVar();
ImGui::PopID();
hover_tip(tip_text);
return clicked;
};
// A section header that folds its contents away. ImGui's own CollapsingHeader draws a filled bar
// that is louder than anything else in this window, so this is the same triangle-and-text the
// displacement panel's "More settings" link uses, at heading weight.
const auto section = [&](const char *id, const wxString &label, bool &open) {
const float arrow = std::round(ImGui::GetFontSize() * 0.55f);
const ImVec2 ts = ImGui::CalcTextSize(label.ToUTF8().data());
const ImVec2 p = ImGui::GetCursorScreenPos();
ImGui::PushID(id);
if (ImGui::InvisibleButton("##sec", ImVec2(panel_w, ts.y + gap_s)))
open = !open;
const bool hot = ImGui::IsItemHovered();
ImDrawList *dl = ImGui::GetWindowDrawList();
const ImU32 col = ImGui::GetColorU32(hot ? ImGuiCol_Text : ImGuiCol_TextDisabled);
const float cx = p.x + 0.5f * arrow, cy = p.y + 0.5f * ts.y, hs = 0.32f * arrow;
if (open)
dl->AddTriangleFilled(ImVec2(cx - hs, cy - 0.5f * hs), ImVec2(cx + hs, cy - 0.5f * hs),
ImVec2(cx, cy + 0.5f * hs), col);
else
dl->AddTriangleFilled(ImVec2(cx - 0.5f * hs, cy - hs), ImVec2(cx - 0.5f * hs, cy + hs),
ImVec2(cx + 0.5f * hs, cy), col);
dl->AddText(ImVec2(p.x + arrow + gap_s, p.y), col, label.ToUTF8().data());
ImGui::PopID();
return open;
};
// The integer twin of float_row, for the colour count.
const auto int_row = [&](const char *id, const wxString &label, int *v, int v_min, int v_max,
const char *format) -> bool {
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
m_imgui->text(label);
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX());
return ImGui::SliderInt(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp);
};
// Framed icon toggle, the displacement panel's one cut down to what this window needs.
const auto icon_toggle = [&](int uid, const std::string &iconfile, bool active, const wxString &label,
const wxString &tip_text) -> bool {
const auto it = m_panel_icon_map.find(iconfile);
const float pad = std::max(1.f, std::round(icon_sm * 0.14f));
bool clicked = false;
ImGui::PushID(uid);
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.f);
ImGui::PushStyleColor(ImGuiCol_Button, active ? ImVec4(orca.x, orca.y, orca.z, 0.22f) : ImVec4(0.f, 0.f, 0.f, 0.f));
if (it != m_panel_icon_map.end() && int(it->second.size()) > (active ? 1 : 0) &&
it->second[active ? 1 : 0]->is_valid()) {
const IconManager::Icon &ic = *it->second[active ? 1 : 0];
clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic.tex_id,
ImVec2(icon_sm - 2.f * pad, icon_sm - 2.f * pad), ic.tl, ic.br, int(pad));
} else {
clicked = ImGui::Button(label.ToUTF8().data(), ImVec2(0.f, icon_sm));
}
ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(),
ImGui::GetColorU32(active ? orca : col_frame), style.FrameRounding);
ImGui::PopStyleColor();
ImGui::PopStyleVar();
ImGui::PopID();
hover_tip(tip_text);
return clicked;
};
TextureDisplacementLayer *layer = mv != nullptr ? active_layer() : nullptr;
{
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
m_imgui->push_bold_font();
m_imgui->text(wxString::FromUTF8(get_name(false)));
m_imgui->pop_bold_font();
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + panel_w - icon_sm));
if (icon_button(920, "canvas_drag.svg", m_undocked ? _L("Dock panel") : _L("Undock panel"),
_L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it back "
"against the right edge.")))
m_undocked = !m_undocked;
}
ImGui::Separator();
// Everything between the title and the footer scrolls as one, so Apply and Close stay on screen
// on a short canvas instead of being the first thing pushed off the bottom. Sized from last
// frame's content and footer heights, as the displacement panel's body is.
const float bottom = m_undocked ? ImGui::GetIO().DisplaySize.y : bottom_limit;
const float max_body_h = std::max(m_imgui->scaled(8.f), bottom - ImGui::GetCursorScreenPos().y -
m_panel_footer_h - style.WindowPadding.y -
style.ItemSpacing.y);
const float body_h = m_panel_body_h > 0.f ? std::min(m_panel_body_h + 1.f, max_body_h) : max_body_h;
const float scrollbar_w = m_imgui->scaled(0.5f);
const ImVec4 grab = wxGetApp().dark_mode() ? ImVec4(1.f, 1.f, 1.f, 0.26f) : ImVec4(0.f, 0.f, 0.f, 0.26f);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, scrollbar_w);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 0.5f * scrollbar_w);
ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.f, 0.f, 0.f, 0.f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, grab);
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(grab.x, grab.y, grab.z, 0.45f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(grab.x, grab.y, grab.z, 0.65f));
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.f, 0.f, 0.f, 0.f));
ImGui::BeginChild("##image_body", ImVec2(panel_w, body_h), false);
// The picture, shown whole and the full width of the window: it is the thing being placed, so a
// row-sized thumbnail beside a file name is the wrong size for it. Clicking it opens the library,
// which is also where Import lives, and before anything is loaded the same box is an empty frame
// with a plus in it - one target, in the same place, whichever state the tool is in.
const auto open_library = [&](int slot) {
m_picker_slot = slot;
m_picker_open_request = true;
};
// A layer with no picture on it is the starting state, not a broken one: the tool creates the
// layer empty and the panel stays on the frame below until a picture has been chosen.
const bool has_picture = layer != nullptr && !layer->empty();
const bool placed = mv != nullptr && mv->is_texture_displacement_painted();
// The handle comes up by itself the first moment there is something to anchor it to, so the
// picture can be nudged straight after the click that placed it without hunting for a toggle.
// Armed once: turning it back off has to stay off, not be undone on the next frame.
if (has_picture && placed && !m_image_handle_armed) {
m_image_handle_armed = true;
if (update_adjust_anchor())
m_adjust_texture_mode = true;
}
if (!has_picture) {
const float box_h = std::round(panel_w * 0.6f);
const ImVec2 p0 = ImGui::GetCursorScreenPos();
m_imgui->disabled_begin(busy || mv == nullptr);
const bool clicked = ImGui::InvisibleButton("##load", ImVec2(panel_w, box_h));
m_imgui->disabled_end();
const bool hot = ImGui::IsItemHovered();
ImDrawList *dl = ImGui::GetWindowDrawList();
const ImU32 col = ImGui::GetColorU32(hot ? orca : col_frame);
dl->AddRect(p0, ImVec2(p0.x + panel_w, p0.y + box_h), col, style.FrameRounding);
const float cx = p0.x + 0.5f * panel_w, cy = p0.y + 0.5f * box_h;
const float arm = std::round(box_h * 0.18f), thick = std::max(1.f, std::round(box_h * 0.035f));
dl->AddLine(ImVec2(cx - arm, cy), ImVec2(cx + arm, cy), col, thick);
dl->AddLine(ImVec2(cx, cy - arm), ImVec2(cx, cy + arm), col, thick);
hover_tip(_u8L("Choose a picture to put on the model."));
if (clicked) {
if (layer == nullptr)
add_texture_layer(); // created with no picture on it, on purpose
open_library(layer != nullptr ? layer->slot : m_active_layer_slot);
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
ImGui::TextDisabled("%s", _u8L("Choose a picture, then click the model to put it there.").c_str());
ImGui::PopTextWrapPos();
} else {
// The colour texture is the corrected image at full resolution, which is exactly what should
// be previewed - move a correction slider and the box follows. A grayscale picture has no
// colour texture, so it falls back to the height thumbnail.
GLTexture *shown = get_layer_color_texture(*layer);
if (shown == nullptr)
shown = get_layer_thumbnail(*layer);
const float aspect = std::max(0.05f, layer_texture_aspect(*layer));
const float box_h = std::clamp(std::round(panel_w / aspect), std::round(panel_w * 0.35f),
std::round(panel_w * 1.1f));
const ImVec2 box_min = ImGui::GetCursorScreenPos();
bool box_hit = false;
if (shown != nullptr) {
box_hit = ImGui::ImageButton((ImTextureID) (intptr_t) shown->get_id(), ImVec2(panel_w, box_h),
ImVec2(0.f, 0.f), ImVec2(1.f, 1.f), 0);
} else {
box_hit = ImGui::Button("?##thumb", ImVec2(panel_w, box_h));
}
hover_tip(m_image_bg_picking ?
_u8L("Click the background, here or on the model.") :
_u8L("Choose a different picture. Where you placed it and how it is corrected stay as "
"they are."));
if (box_hit) {
if (!m_image_bg_picking) {
open_library(layer->slot);
} else {
// Read straight out of the decoded image rather than off the screen: the preview is
// scaled, and it already has the corrections applied, so a screen read would key the
// corrected colour against itself.
const ImVec2 rel((ImGui::GetIO().MousePos.x - box_min.x) / panel_w,
(ImGui::GetIO().MousePos.y - box_min.y) / box_h);
const DecodedHeightTexture &tex = decode_height_texture(*layer);
if (tex.has_color()) {
const int px = std::clamp(int(rel.x * float(tex.width)), 0, tex.width - 1);
const int py = std::clamp(int(rel.y * float(tex.height)), 0, tex.height - 1);
const size_t i = (size_t(py) * size_t(tex.width) + size_t(px)) * 3;
for (int c = 0; c < 3; ++c)
layer->bg_color[c] = float(tex.rgb[i + size_t(c)]) / 255.f;
layer->bg_from_corners = false;
layer->bg_remove = true;
m_preview_params_dirty = true;
}
m_image_bg_picking = false;
}
}
const std::string name = layer->name.empty() ? _u8L("Image") : layer->name;
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
ImGui::TextDisabled("%s", name.c_str());
ImGui::PopTextWrapPos();
if (!height_texture_has_color(*layer)) {
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
m_imgui->warning_text(_L("This image is grayscale, so it has no colors to print. Choose a color picture."));
ImGui::PopTextWrapPos();
}
// Placing it: one click floods the surface it lands on, out to the first fold sharper than
// the angle below. No brush, no erase toggle - on a flat side or a cube face the click alone
// is the whole gesture, and the slider is there for the cases where it is not.
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
ImGui::TextDisabled("%s", placed ? _u8L("Click the model to move the picture somewhere else.").c_str() :
_u8L("Click the model to put the picture there.").c_str());
ImGui::PopTextWrapPos();
float fill_angle = m_smart_fill_angle;
if (float_row("##fill_angle", _L("Spread"), &fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°", false))
m_smart_fill_angle = fill_angle;
hover_tip(_u8L("How far a click spreads from the spot you hit: it keeps going while the surface stays "
"flatter than this, and stops at a sharper fold. Small values keep it to one face."));
ImGui::Separator();
if (section("place", _L("Placement"), m_image_sec_place)) {
// Projection, minus Unwrap: that one is an editor's worth of UI (seams, islands, the UV pane)
// for a tool whose whole point is a click and a few sliders.
{
struct Mapping { const char *file; wxString label; wxString tip; TextureProjectionMethod method; };
const Mapping mappings[] = {
{ "menu_obj_cube.svg", _L("Flat"),
_L("Flat - lays the picture straight onto the surface from whichever axis it faces, and blends "
"where it wraps around an edge. The right choice for a face or a flat side."),
TextureProjectionMethod::Triplanar },
{ "menu_obj_cylinder.svg", _L("Around"),
_L("Around - wraps the picture around the area, for a round part like a mug or a tube."),
TextureProjectionMethod::Cylindrical },
{ "menu_obj_sphere.svg", _L("Ball"),
_L("Ball - wraps the picture around the area in both directions, for a dome or a sphere."),
TextureProjectionMethod::Spherical },
{ "texture_displacement_map_view.svg", _L("From view"),
_L("From view - throws the picture onto the area from where you are looking now, like a slide "
"projector. Line the view up with the surface first, because faces turned away stretch."),
TextureProjectionMethod::ViewProjected },
};
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
// Plain text, like every other label in this window: the displacement panel greys its
// headings because it has many, and here there are five rows in total.
m_imgui->text(_L_CONTEXT("Mapping", "Texture Displacement"));
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
for (int mi = 0; mi < int(IM_ARRAYSIZE(mappings)); ++mi) {
if (mi > 0)
ImGui::SameLine(0.f, gap_s);
const bool on = layer->projection_method == mappings[mi].method;
if (icon_toggle(900 + mi, mappings[mi].file, on, mappings[mi].label, mappings[mi].tip) && !on) {
// Capture the current view the moment "From view" is chosen, so it does something
// sensible immediately rather than projecting from a stale direction.
if (mappings[mi].method == TextureProjectionMethod::ViewProjected)
capture_view_projection(*layer);
layer->projection_method = mappings[mi].method;
m_preview_params_dirty = true;
}
}
}
if (layer->projection_method == TextureProjectionMethod::ViewProjected) {
if (m_imgui->button(_u8L("Capture current view"))) {
capture_view_projection(*layer);
m_preview_params_dirty = true;
}
hover_tip(_u8L("Throws the picture on again from where you are looking now. Orbit the model, press "
"this, and it is re-laid from the new angle."));
}
m_preview_params_dirty |= float_row("##size", _L("Size"), &layer->tiling_scale, 1.f, 300.f, "%.1f mm", true);
hover_tip(_u8L("How wide the picture is on the model, in millimetres."));
m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer->rotation_deg, 0.f, 360.f, "%.0f°", false);
hover_tip(_u8L("Turns the picture on the surface, for lining it up with an edge of the model."));
// Sliding it into place, and whether it repeats. Both belong on one row: the handle is the
// coarse control and Tile decides what it is sliding - one copy, or a pattern.
{
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
m_imgui->text(_L("Move"));
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
if (icon_toggle(910, "texture_displacement_adjust.svg", m_adjust_texture_mode, _L("Move on the model"),
_L("Move on the model - drag the handle that appears on the surface to slide the "
"picture around, or one of its two arrows to slide it along just that axis."))) {
if (!m_adjust_texture_mode) {
update_model_object(); // flush any pending placement before anchoring to it
if (update_adjust_anchor())
m_adjust_texture_mode = true;
else
show_error(nullptr, _u8L("Click the model to put the picture somewhere first."));
} else {
m_adjust_texture_mode = false;
m_adjust_drag_handle = AdjustHandle::None;
}
}
ImGui::SameLine(0.f, gap_s * 2.f);
if (m_imgui->bbl_checkbox(_L("Tile"), layer->tile_enabled))
m_preview_params_dirty = true;
hover_tip(_u8L("Repeats the picture over the area instead of placing a single copy. Off by "
"default, because a photo or a logo goes on once; Size then sets how big that "
"one copy is."));
}
// The one number that decides how sharp the printed picture is. Each triangle prints in a
// single filament, so the mesh under the paint has to be finer than the detail wanted out of
// the image - and the budget above is what stops that running away.
{
TextureDisplacementOptions &opts = mv->texture_displacement_options;
float edge = opts.v2_refine_mm;
m_imgui->disabled_begin(busy);
if (float_row("##resolution", _L("Detail"), &edge, 0.02f, 2.f, "%.2f mm", true)) {
opts.v2_refine_mm = edge;
m_preview_params_dirty = true;
}
m_imgui->disabled_end();
hover_tip(_u8L("How fine the mesh is made under the picture, in millimetres. Each triangle prints "
"in one filament, so this is how sharp an edge between two colors can come out. "
"Smaller costs triangles fast: halving it needs four times as many."));
// Refining past the budget is not an error - the bake simplifies back down to it - but the
// result then carries less of the picture than this slider asks for, and without saying so
// the only sign is a print that came out softer than expected.
const size_t budget = size_t(std::max(0, opts.v2_max_triangles_k)) * 1000;
const size_t needed = estimated_refined_triangles(*mv, edge);
if (budget > 0 && needed > budget * 5 / 4) {
const auto count = [](size_t n) {
return n >= 1000000 ? Slic3r::format("%1$.1f M", double(n) / 1000000.) :
Slic3r::format("%1% k", (n + 500) / 1000);
};
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w);
m_imgui->warning_text(Slic3r::format(_u8L("Needs about %1% triangles, budget %2% - it will be "
"simplified back down and come out softer."),
count(needed), count(budget)));
ImGui::PopTextWrapPos();
}
}
} // Placement
ImGui::Separator();
if (section("color", _L("Color"), m_image_sec_color)) {
// Colour correction. Every one of these changes which filament each area lands in, which is
// why they are here rather than left to whatever the file happened to contain: a photo that
// prints as mud usually only needs contrast.
m_preview_params_dirty |= float_row("##brightness", _L("Brightness"), &layer->brightness, -1.f, 1.f, "%.2f", false);
hover_tip(_u8L("Lightens or darkens the whole picture before it is matched to your filaments."));
m_preview_params_dirty |= float_row("##contrast", _L("Contrast"), &layer->contrast, -1.f, 1.f, "%.2f", false);
hover_tip(_u8L("Pushes light and dark apart. Raise it when a photo prints as mud; lower it when "
"everything collapses into the lightest and darkest filament."));
m_preview_params_dirty |= float_row("##saturation", _L("Saturation"), &layer->saturation, 0.f, 2.f, "%.2f", false);
hover_tip(_u8L("How strong the colors are. At 0 the picture is gray, so it prints in your grays and "
"blacks; raising it pushes each area towards a purer filament."));
m_preview_params_dirty |= float_row("##hue", _L("Hue"), &layer->hue, -180.f, 180.f, "%.0f°", false);
hover_tip(_u8L("Turns every color of the picture around the color wheel, without changing how light "
"it is. Your filaments are a fixed, arbitrary set, so this is what lets the picture "
"meet them: if you have orange loaded but no red, a small turn puts the reds on it."));
m_preview_params_dirty |= float_row("##gamma", _L("Gamma"), &layer->gamma, 0.2f, 5.f, "%.2f", true);
hover_tip(_u8L("Lifts or drops the midtones without touching the brightest and darkest parts. Use it "
"when the middle of the picture is the part coming out wrong."));
m_preview_params_dirty |= float_row("##sharpen", _L("Sharpen"), &layer->sharpness, 0.f, 1.f, "%.2f", false);
hover_tip(_u8L("Crisps up the edges before the picture is matched to your filaments. Worth more here "
"than in a photo: a soft edge between two colors quantizes into a ragged band of "
"both, and sharpening first makes it land in one place."));
// How many colours the picture may use. 2 is a logo in two flat colours, 4 or 5 a poster,
// off a photograph. This is not the same as the displacement tool's "Mixed colors", which
// caps how many mixed filament slots a bake may create rather than what the picture uses.
{
int count = layer->color_count;
if (int_row("##colors", _L("Colors"), &count, 0, 12, count == 0 ? _u8L("Off").c_str() : "%d")) {
// 1 is not a picture, so the slider steps over it on the way out of Off.
layer->color_count = count == 1 ? 2 : count;
m_preview_params_dirty = true;
}
hover_tip(_u8L("Reduces the picture to this many colors before your filaments are matched to "
"it, so it prints in at most that many. The colors are the ones the picture is "
"actually made of, not an even split of the range. Off leaves it alone."));
}
if (m_imgui->bbl_checkbox(_L("Negative"), layer->color_invert))
m_preview_params_dirty = true;
hover_tip(_u8L("Prints the picture inverted, light for dark and each color for its opposite."));
} // Color
ImGui::Separator();
if (!section("bg", _L("Background"), m_image_sec_bg)) {
// Folded away, but still worth saying whether it is doing anything.
ImGui::SameLine();
ImGui::TextDisabled("%s", layer->bg_remove ? _u8L("removed").c_str() : _u8L("kept").c_str());
} else {
if (m_imgui->bbl_checkbox(_L("Remove background"), layer->bg_remove))
m_preview_params_dirty = true;
hover_tip(_u8L("Leaves the background unprinted, so the object's own color shows through instead of "
"a slab of white behind a logo. A picture that is already transparent needs none of "
"this: those parts are left out either way."));
if (layer->bg_remove) {
// Which colour is being removed, shown rather than described: the swatch is also a
// picker, and the dropper beside it takes the colour from the preview above.
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
m_imgui->text(_L("Color"));
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
float shown_bg[3] = { layer->bg_color[0], layer->bg_color[1], layer->bg_color[2] };
if (layer->bg_from_corners) {
// Nothing has been picked, so show what the corners actually gave: the same median
// the bake will key on, not a placeholder white.
const DecodedHeightTexture &tex = decode_height_texture(*layer);
if (tex.has_color() && tex.width > 1 && tex.height > 1) {
const size_t n = size_t(tex.width) * size_t(tex.height);
const size_t corner[4] = { 0, size_t(tex.width) - 1,
(size_t(tex.height) - 1) * size_t(tex.width), n - 1 };
for (int c = 0; c < 3; ++c) {
std::array<float, 4> v{};
for (int i = 0; i < 4; ++i)
v[size_t(i)] = float(tex.rgb[corner[i] * 3 + size_t(c)]);
std::sort(v.begin(), v.end());
shown_bg[c] = 0.5f * (v[1] + v[2]) / 255.f;
}
}
}
ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX() - icon_sm - gap_s);
if (ImGui::ColorEdit3("##bg_color", shown_bg,
ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel)) {
for (int c = 0; c < 3; ++c)
layer->bg_color[c] = shown_bg[c];
layer->bg_from_corners = false;
m_preview_params_dirty = true;
}
hover_tip(layer->bg_from_corners ?
_u8L("Taken from the four corners of the picture, which is right for a logo on a "
"plain card. Click to choose a color yourself instead.") :
_u8L("The color being removed. Click to change it."));
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(930, "texture_displacement_pipette.svg", m_image_bg_picking, _L("Pick the color"),
_L("Pick the color - then click the background, either in the preview above or on "
"the model itself.")))
m_image_bg_picking = !m_image_bg_picking;
if (!layer->bg_from_corners) {
ImGui::SameLine(0.f, gap_s);
if (m_imgui->button(_u8L("Corners"))) {
layer->bg_from_corners = true;
m_preview_params_dirty = true;
}
hover_tip(_u8L("Back to taking the color from the picture's four corners."));
}
m_preview_params_dirty |= float_row("##bg_tol", _L("Tolerance"), &layer->bg_tolerance, 0.01f, 0.6f, "%.2f", false);
hover_tip(_u8L("How different from that color still counts as background. Raise it when a "
"shadow or a gradient is left behind; lower it when it starts eating the picture."));
}
} // Background
// Outside both sections, because it clears the colour corrections and the background key
// alike, and a reset that lives inside one of them would look like it only reached that far.
ImGui::Separator();
if (m_imgui->button(_u8L("Reset all"))) {
layer->brightness = 0.f;
layer->contrast = 0.f;
layer->saturation = 1.f;
layer->hue = 0.f;
layer->gamma = 1.f;
layer->sharpness = 0.f;
layer->color_count = 0;
layer->bg_remove = false;
layer->bg_tolerance = 0.15f;
layer->bg_from_corners = true;
layer->bg_color[0] = layer->bg_color[1] = layer->bg_color[2] = 1.f;
m_image_bg_picking = false;
layer->color_invert = false;
m_preview_params_dirty = true;
}
hover_tip(_u8L("Puts every correction and the background back to where they started, leaving the "
"picture as it was loaded. Where you placed it and how big it is are not touched."));
}
// The library popup lives in this window's scope, where its open request and the popup itself
// share one ID stack.
{
const ImVec2 win_min = ImGui::GetWindowPos();
const ImVec2 win_sz = ImGui::GetWindowSize();
render_texture_library_popup(win_min, ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y));
}
// ImGui sliders report "changed" on every frame of a drag, and a preview rebuild is real work,
// so with the mouse still down it waits for the release.
if (m_preview_params_dirty && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
rebuild_preview();
m_preview_params_dirty = false;
}
m_panel_body_h = ImGui::GetCursorPosY() - style.ItemSpacing.y;
ImGui::EndChild();
ImGui::PopStyleColor(5);
ImGui::PopStyleVar(2);
const float footer_top = ImGui::GetCursorScreenPos().y;
ImGui::Separator();
{
const bool painted = placed;
const bool no_colors = mv != nullptr && !any_layer_colors(*mv);
// Colour is the only thing this bake produces, so with a grayscale picture there would be
// nothing to show for it - and it is not a cheap no-op: the bake still refines the painted
// area and replaces the mesh with the result.
const bool can_apply = !busy && mv != nullptr && painted && !no_colors;
const float button_h = std::round(frame_h * 1.25f);
const float button_w = std::max({ ImGui::CalcTextSize(_u8L("Apply").c_str()).x,
ImGui::CalcTextSize(_u8L("Close").c_str()).x,
ImGui::CalcTextSize(_u8L("Stop").c_str()).x,
ImGui::CalcTextSize(_u8L("Working...").c_str()).x }) +
m_imgui->scaled(0.5f);
const float row_y = ImGui::GetCursorPosY();
const float icon_h = 21.f * scale;
const float row_h = std::max(icon_h, button_h);
ImGui::SetCursorPosY(row_y + (row_h - icon_h) * .5f);
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(int(m_imgui->scaled(.5f)), style.ItemSpacing.y));
GLGizmoUtils::render_wiki_guide_button(m_parent, scale,
"https://www.orcaslicer.com/wiki/print_prepare/prepare_texture_displacement");
ImGui::PopStyleVar(1);
ImGui::SameLine(panel_w - button_w * 2 - style.ItemSpacing.x);
GLGizmoUtils::push_orca_button_style();
m_imgui->push_bold_font();
m_imgui->disabled_begin(!can_apply);
ImGui::SetCursorPosY(row_y + (row_h - button_h) * .5f);
const std::string apply_label = busy ? _u8L("Working...") : _u8L("Apply");
if (ImGui::Button((apply_label + "##apply").c_str(), ImVec2(button_w, button_h)))
bake_standard();
m_imgui->disabled_end();
m_imgui->pop_bold_font();
GLGizmoUtils::pop_orca_button_style();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
m_imgui->tooltip(!has_picture ? _u8L("Choose a picture first.") :
!painted ? _u8L("Click the model to place the picture first.") :
no_colors ? _u8L("This picture is grayscale, so there is nothing to print. "
"Choose a color picture.") :
_u8L("Prints the picture where you placed it, in the nearest of your "
"loaded filaments. The surface itself is left exactly where it "
"is: this adds color, not relief."),
wrap_w);
ImGui::SameLine();
if (busy) {
if (ImGui::Button((_u8L("Stop") + "##stop").c_str(), ImVec2(button_w, button_h)))
wxGetApp().plater()->get_ui_job_worker().cancel_all();
hover_tip(_u8L("Stops it. Whatever it had already finished stays on the model, and can be undone."));
} else {
if (ImGui::Button((_u8L("Close") + "##close").c_str(), ImVec2(button_w, button_h)))
m_parent.reset_all_gizmos();
hover_tip(_u8L("Closes the tool. Your picture and where you put it stay with the model."));
}
}
m_panel_footer_h = ImGui::GetCursorScreenPos().y - footer_top;
// Both heights feed back into next frame's layout, so the window needs one more frame whenever
// either of them moved.
if (std::abs(m_panel_body_h - previous_body_h) > 0.5f || std::abs(m_panel_footer_h - previous_footer_h) > 0.5f)
m_parent.request_extra_frame();
// Last, on the foreground list, so it covers the panel as well as the view.
render_image_drop_hint();
GizmoImguiEnd();
ImGuiWrapper::pop_toolbar_style();
}
} // namespace Slic3r::GUI
+46
View File
@@ -0,0 +1,46 @@
#ifndef slic3r_GLGizmoImage_hpp_
#define slic3r_GLGizmoImage_hpp_
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
#include "slic3r/GUI/I18N.hpp"
#include <string>
#include <wx/string.h>
namespace Slic3r::GUI {
class GLCanvas3D;
// "Put this picture on the model": the texture-displacement gizmo with the relief taken out of it.
//
// There is no second engine here and no copy of one. It is GLGizmoTextureDisplacement with
// image_mode() on, which pins every layer to zero depth and colour on (see
// apply_image_mode_presets()) and leaves out the controls that only mean something once the surface
// moves. Painting, the layer stack, the texture library, the previews and the bake are the very same
// code, reached the very same way - a zero-depth bake refines the painted area and writes the
// colours exactly as a relief bake does, it just displaces by nothing.
//
// Its own toolbar button because the two are different jobs, not two settings of one: a user who
// wants a photo on a part should not have to know what a height map is, or find the one slider that
// has to be zero.
class GLGizmoImage : public GLGizmoTextureDisplacement
{
public:
GLGizmoImage(GLCanvas3D &parent, const std::string &icon_filename, unsigned int sprite_id);
protected:
bool image_mode() const override { return true; }
bool on_init() override;
std::string on_get_name() const override;
wxString handle_snapshot_action_name(bool shift_down, Button button_down) const override;
std::string get_gizmo_entering_text() const override { return _u8L("Entering Image painting"); }
std::string get_gizmo_leaving_text() const override { return _u8L("Leaving Image painting"); }
std::string get_action_snapshot_name() const override { return _u8L("Image editing"); }
};
} // namespace Slic3r::GUI
#endif // slic3r_GLGizmoImage_hpp_
@@ -418,10 +418,18 @@ std::unique_ptr<GLTexture> upload_color_texture(const DecodedHeightTexture &deco
for (int c = 0; c < 3; ++c)
sum[c] += decoded.rgb[si + size_t(c)];
}
// Coverage rides in the fourth channel, box-filtered with the colour, so the shader can
// leave the surface alone where the picture is not there (see the colour path in
// texture_displacement_shaded.fs).
unsigned int sum_a = 0;
if (decoded.has_alpha())
for (int sy = y0; sy < y1 && sy < decoded.height; ++sy)
for (int sx = x0; sx < x1 && sx < decoded.width; ++sx)
sum_a += decoded.alpha[size_t(sy) * size_t(decoded.width) + size_t(sx)];
const size_t di = (size_t(y) * size_t(w) + size_t(x)) * 4;
for (int c = 0; c < 3; ++c)
rgba[di + size_t(c)] = (n > 0) ? static_cast<unsigned char>(sum[size_t(c)] / n) : 0;
rgba[di + 3] = 255;
rgba[di + 3] = (decoded.has_alpha() && n > 0) ? static_cast<unsigned char>(sum_a / n) : 255;
}
auto texture = std::make_unique<GLTexture>();
@@ -554,6 +562,7 @@ void GLGizmoTextureDisplacement::on_shutdown()
m_palette_pure_quantizer = nullptr;
m_palette_filaments.clear();
m_palette_images.clear();
m_palette_adjusts.clear();
m_mix_ranking.reset();
m_palette_slots.clear();
m_palette_changed = false;
@@ -584,6 +593,8 @@ void GLGizmoTextureDisplacement::on_shutdown()
m_seam_edit_mode = false;
m_adjust_drag_handle = AdjustHandle::None;
m_adjust_anchor_valid = false;
m_image_handle_armed = false;
m_file_drag_over = false;
// The pane is request-only: closing the gizmo drops the request, so reopening it later doesn't
// silently reopen the pane too.
m_show_uv_editor = false;
@@ -783,8 +794,21 @@ bool GLGizmoTextureDisplacement::on_mouse(const wxMouseEvent &mouse_event)
{
if (m_seam_edit_mode)
return on_mouse_seam(mouse_event);
if (m_adjust_texture_mode)
return on_mouse_adjust_texture(mouse_event);
// The background dropper takes the next click wherever it lands, the model included, so it comes
// before both the handle and the paint.
if (m_image_bg_picking && mouse_event.LeftDown() &&
pick_background_at(Vec2d(mouse_event.GetX(), mouse_event.GetY())))
return true;
if (m_adjust_texture_mode) {
if (on_mouse_adjust_texture(mouse_event))
return true;
// The displacement tool turns this mode on deliberately, so there a click off the handle
// doing nothing is the point. The Image tool has it on from the start, so the same click has
// to fall through and place the picture, or turning it on would disable the tool's one
// gesture.
if (!image_mode())
return false;
}
const bool handled = GLGizmoPainterBase::on_mouse(mouse_event);
// A consumed drag/click is a paint (or erase) event: the base class has already updated the live
@@ -1244,9 +1268,33 @@ indexed_triangle_set GLGizmoTextureDisplacement::patch_in_world(const indexed_tr
return world;
}
std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer) const
std::vector<Vec2f> GLGizmoTextureDisplacement::model_uv_corners(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
{
const ModelVolume *mv = texture_volume();
if (layer.projection_method != TextureProjectionMethod::ModelUV || patch_source == nullptr || mv == nullptr ||
!mv->has_texture_uv())
return {};
// The channel is indexed against the volume's *local* mesh, and a uv is a coordinate in the
// texture rather than on the model, so unlike every projected method this never goes through
// patch_in_world() - there is no millimetre measurement here for a world scale to change.
std::vector<Vec2f> uv = compute_model_corner_uvs(mv->mesh().its, mv->texture_uv, local_patch, *patch_source);
for (Vec2f &p : uv)
// Already a uv: no tiling scale, no aspect correction - see apply_uv_transform().
p = apply_uv_transform(p, layer, 1.f, /* normalized_uv */ true);
return uv;
}
std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
{
if (layer.projection_method == TextureProjectionMethod::ModelUV)
// Collapsed to one per vertex the same way the unwrap is - a vertex has one position, so the
// displacement can only read one height for it.
return collapse_corner_uvs(local_patch, model_uv_corners(local_patch, layer, patch_source));
// The bake maps the texture in world millimetres, so "Tile size (mm)" means the same thing on a
// scaled instance as it does on an untouched one (see build_texture_displacement()). Everything
// that has to agree with the bake - the fast preview's uvs, the checker/distortion overlays -
@@ -1307,8 +1355,11 @@ int GLGizmoTextureDisplacement::layer_projection_frame(const indexed_triangle_se
}
std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_corner_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer) const
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
{
if (layer.projection_method == TextureProjectionMethod::ModelUV)
return model_uv_corners(local_patch, layer, patch_source);
if (layer.projection_method == TextureProjectionMethod::LSCM) {
const indexed_triangle_set patch = patch_in_world(local_patch);
const float aspect = layer_texture_aspect(layer);
@@ -1318,7 +1369,7 @@ std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_corner_uvs(const in
return uv;
}
// Single-valued per point: fan the per-vertex result out over the corners.
const std::vector<Vec2f> per_vertex = compute_layer_vertex_uvs(local_patch, layer);
const std::vector<Vec2f> per_vertex = compute_layer_vertex_uvs(local_patch, layer, patch_source);
if (per_vertex.size() != local_patch.vertices.size())
return {};
std::vector<Vec2f> corner(local_patch.indices.size() * 3);
@@ -1339,7 +1390,11 @@ void GLGizmoTextureDisplacement::rebuild_shaded_preview_mesh()
// Uses the *live* selector (not the flushed model facet data), so this reflects an in-progress
// stroke immediately rather than only once it ends - the point of this preview mode is to be
// the fast, no-CPU-meshing one.
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
// The source map comes along because a ModelUV layer's uvs are read per base-mesh triangle; every
// other mapping ignores it.
std::vector<int> patch_source;
const indexed_triangle_set patch =
m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source);
if (patch.indices.empty())
return;
@@ -1363,7 +1418,8 @@ void GLGizmoTextureDisplacement::rebuild_shaded_preview_mesh()
// Per *corner*, not per vertex: the mesh below is flat (unshared) anyway, so each triangle can
// carry its own chart's UVs - see compute_layer_corner_uvs().
const TextureDisplacementLayer *active = active_layer();
std::vector<Vec2f> corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active) : std::vector<Vec2f>{};
std::vector<Vec2f> corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active, &patch_source) :
std::vector<Vec2f>{};
m_shaded_preview_uses_vertex_uv = corner_uv.size() == patch.indices.size() * 3;
if (!m_shaded_preview_uses_vertex_uv)
corner_uv.clear();
@@ -1587,6 +1643,9 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh()
shader->set_uniform("height_tex", 0);
shader->set_uniform("height_tex_texel", Vec2f(1.f / float(tex->get_width()), 1.f / float(tex->get_height())));
shader->set_uniform("depth_mm", layer->depth_mm);
// Without this the preview wrapped the image and showed a repeating pattern where the bake
// places a single copy.
shader->set_uniform("tile_enabled", layer->tile_enabled);
shader->set_uniform("tiling_scale", layer->tiling_scale);
// Read off the uploaded texture rather than the decoded one: they are the same image, and this is
// the aspect the sampler will actually see.
@@ -2030,7 +2089,9 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
const ModelVolume *mv = texture_volume();
if (mv == nullptr || m_triangle_selectors.empty())
return;
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
std::vector<int> patch_source; // only ModelUV reads it; see compute_layer_vertex_uvs()
const indexed_triangle_set patch =
m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source);
if (patch.indices.empty())
return;
// Everything below works in the *patch's* vertex space, not the mesh's. Those agree only until a
@@ -2044,7 +2105,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
// The checker samples wherever the projection puts it; the projections the shader can't
// reconstruct (LSCM, ViewProjected) get a precomputed per-vertex uv, the rest project in-shader.
std::vector<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer);
std::vector<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer, &patch_source);
const bool have_uvs = uv.size() == patch.vertices.size();
m_uvcheck_uses_vertex_uv = have_uvs;
m_uvcheck_projection_mode = have_uvs ? 0 :
@@ -2053,7 +2114,7 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
// different uv in each island it borders, so the shared-vertex form drew one triangle per face from
// a neighbouring island's placement. Only the *drawing* needs this; the distortion metric below is
// a per-vertex average by construction and keeps using `uv`.
const std::vector<Vec2f> corner_uv = compute_layer_corner_uvs(patch, *layer);
const std::vector<Vec2f> corner_uv = compute_layer_corner_uvs(patch, *layer, &patch_source);
const bool have_corner_uvs = have_uvs && corner_uv.size() == patch.indices.size() * 3;
// Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible.
@@ -2329,6 +2390,8 @@ void GLGizmoTextureDisplacement::queue_preview_job()
if (input.options.pipeline_v2)
input.options.v2_max_triangles_k = 0;
input.volume_to_world = texture_displacement_volume_to_world(*mv);
if (mv->has_texture_uv())
input.model_uv = mv->texture_uv;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data();
// Captured here rather than read in the handler: the palette is main-thread state, and the preview
@@ -3647,6 +3710,11 @@ void GLGizmoTextureDisplacement::update_model_object()
wxGetApp().obj_list()->update_info_items(std::find(mos.begin(), mos.end(), mo) - mos.begin());
m_parent.post_event(SimpleEvent(EVT_GLCANVAS_SCHEDULE_BACKGROUND_PROCESS));
rebuild_preview();
// The on-canvas handle is anchored to the paint patch, so a stroke that moved the paint has
// to move it with them. Matters most in the Image tool, where the handle is on from the
// start and a click moves the picture to a different part of the model.
if (m_adjust_texture_mode)
update_adjust_anchor();
}
}
@@ -3756,6 +3824,7 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
// a monochrome ("normal", grey in the current theme) and an original-colour variant.
static const std::vector<std::string> names = {
"texture_displacement_brush.svg", "texture_displacement_face.svg", "texture_displacement_connected_area.svg",
"texture_displacement_pipette.svg", // the Image panel's background dropper
"texture_displacement_real_preview.svg", "texture_displacement_fast_preview.svg",
"texture_displacement_checker.svg", "texture_displacement_distortion.svg",
"texture_displacement_wireframe.svg", "texture_displacement_cross.svg",
@@ -3765,7 +3834,8 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
// Paint / Erase, brush cursor shape, mapping, tiling, placement and the dock toggle.
"texture_displacement_add.svg", "texture_displacement_add_negative.svg", "circle_paint.svg",
"menu_obj_sphere.svg", "menu_obj_cube.svg", "menu_obj_cylinder.svg", "texture_displacement_map_unwrap.svg",
"texture_displacement_map_view.svg", "texture_displacement_tile_repeat.svg", "menu_mirror_x.svg",
"texture_displacement_map_view.svg", "texture_displacement_map_model_uv.svg",
"texture_displacement_tile_repeat.svg", "menu_mirror_x.svg",
"texture_displacement_adjust.svg", "canvas_drag.svg", "texture_displacement_move_up.svg",
"texture_displacement_move_down.svg", "texture_displacement_drag.svg",
"texture_displacement_select_all.svg", "texture_displacement_erase_all.svg",
@@ -3814,16 +3884,32 @@ void GLGizmoTextureDisplacement::add_texture_layer()
TextureDisplacementLayer layer;
layer.slot = free_slot;
// A model that came with a UV channel is mapped by it. Re-unwrapping such a model throws away the
// layout it was authored against and can only be worse than using it, so the default follows the
// model rather than the other way round; the Mapping row still offers every other method.
if (mv->has_texture_uv())
layer.projection_method = TextureProjectionMethod::ModelUV;
// Start the layer off on the first library texture rather than on nothing at all: a textureless
// layer looks broken (painting on it appears to do nothing, because there is no height map to
// displace by). The user swaps it for another from the layer's own picker.
const std::vector<TextureLibraryEntry> &library = texture_library();
if (!library.empty())
if (const LibraryTexture *tex = get_library_texture(library.front().path)) {
layer.name = library.front().name;
layer.path = library.front().path;
layer.image_data = tex->image_data;
}
//
// Not in the Image tool: there the layer deliberately starts with no picture at all, so the panel
// stays on its empty frame until the user has chosen one. Picking a shipped height map for them
// would be choosing the wrong thing twice over - it is not their picture, and most of that
// library is grayscale, which an image bake cannot print.
if (image_mode()) {
// One copy, not a repeating pattern: a photo or a logo is placed once. The panel's Tile
// checkbox is there for the cases that do want it, so this is a default and not a pin.
layer.tile_enabled = false;
} else {
const std::vector<TextureLibraryEntry> &library = texture_library();
if (!library.empty())
if (const LibraryTexture *tex = get_library_texture(library.front().path)) {
layer.name = library.front().name;
layer.path = library.front().path;
layer.image_data = tex->image_data;
}
}
mv->texture_displacement_layers.push_back(std::move(layer));
// A new layer opens with every setting showing; there is nothing on it yet to hide settings from.
m_layer_expanded[size_t(free_slot)] = true;
@@ -4018,15 +4104,19 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
return n;
};
ImGui::TextDisabled("%s", _u8L("Built-in").c_str());
{
// The shipped library is grayscale height maps, which is what the displacement tool wants and the
// opposite of what an image bake can use: it prints colour, and a height map has none. So the
// Image tool offers only the user's own pictures, and the section is left out entirely rather
// than shown full of things that would all warn when picked.
if (!image_mode()) {
ImGui::TextDisabled("%s", _u8L("Built-in").c_str());
const ImVec2 origin = ImGui::GetCursorScreenPos();
const int count = grid(origin, false);
end_grid(origin, count);
if (count == 0)
ImGui::TextDisabled("%s", _u8L("No textures found.").c_str());
}
ImGui::TextDisabled("%s", _u8L("My textures").c_str());
ImGui::TextDisabled("%s", image_mode() ? _u8L("My images").c_str() : _u8L("My textures").c_str());
{
const ImVec2 origin = ImGui::GetCursorScreenPos();
const int count = grid(origin, true);
@@ -4034,15 +4124,20 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (ImGui::Button("+##import_texture", ImVec2(tile, tile)))
import_clicked = true;
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer "
"bakes from and saved to your personal texture folder, kept separate from the "
"textures shipped with OrcaSlicer."),
m_imgui->tooltip(image_mode() ?
_u8L("Import a picture from your computer. Its colors are kept, and it is saved to "
"your personal folder so it is here next time.") :
_u8L("Import your own image as a height map. It is converted to the format the "
"slicer bakes from and saved to your personal texture folder, kept separate "
"from the textures shipped with OrcaSlicer."),
m_imgui->scaled(20.f));
end_grid(origin, count + 1);
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + grid_w);
ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str());
ImGui::TextDisabled("%s", image_mode() ?
_u8L("PNG, JPG or BMP. Hover one to remove it.").c_str() :
_u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str());
ImGui::PopTextWrapPos();
if (chosen || import_clicked)
@@ -4954,15 +5049,22 @@ const std::vector<GLGizmoTextureDisplacement::PaletteEntry> &GLGizmoTextureDispl
// The images themselves rather than their addresses, so a freed and reallocated image can never
// pass for the old one. Whether one has colour, and whether its colours are flat, follows from it.
std::vector<std::shared_ptr<std::vector<unsigned char>>> images;
// ... and what each one is corrected by, which changes the colours the mixes are ranked against
// without changing the image it came from.
std::vector<ColorAdjustKey> adjusts;
if (mv != nullptr)
for (const TextureDisplacementLayer &layer : mv->texture_displacement_layers)
if (layer.color_enabled && !layer.empty())
if (layer.color_enabled && !layer.empty()) {
images.push_back(layer.image_data);
adjusts.push_back(ColorAdjustKey::of(layer));
}
const bool ranking_stale = filaments != m_palette_filaments || images != m_palette_images;
const bool ranking_stale = filaments != m_palette_filaments || images != m_palette_images ||
adjusts != m_palette_adjusts;
if (ranking_stale) {
m_palette_filaments = std::move(filaments);
m_palette_images = std::move(images);
m_palette_adjusts = std::move(adjusts);
m_mix_ranking.reset();
}
if (mixing && !m_mix_ranking)
@@ -5039,9 +5141,12 @@ std::vector<GLGizmoTextureDisplacement::MixTarget> GLGizmoTextureDisplacement::m
continue;
TextureDisplacementLayer raw = layer;
raw.smoothing = 0.f;
const DecodedHeightTexture tex = decode_height_texture(raw);
DecodedHeightTexture tex = decode_height_texture(raw);
if (!tex.has_color())
continue;
// Corrected first: the mixes are chosen to cover the colours the bake will actually ask for,
// and a darkened or desaturated image asks for different ones.
apply_color_adjustments(tex, layer);
// 16 levels per channel, each bin keeping the mean of the colours that fell in it: a coarse grid
// to gather on, without snapping every colour to a bin corner.
@@ -5291,7 +5396,8 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve
TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers, const TextureDisplacementPrepareParams &params,
const std::vector<TextureDisplacementLayer> &layers, const std::vector<Vec2f> &model_uv,
const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings, const DisplacementProgressFn &progress,
BakeStageRecorder *debug)
{
@@ -5318,6 +5424,13 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
bool had_paint = false;
for (const TriangleSelector::TriangleSplittingData &m : masks)
had_paint = had_paint || TriangleSelector::has_facets(m, EnforcerBlockerType::ENFORCER);
// The model's own UV channel for whatever `mesh` currently is. Every stage below keeps the input's
// surface - it evens out, refines or cuts the triangles lying on it - so each corner reads its uv
// straight off `base`'s channel at its own position instead of being carried stage by stage, which
// would compound each stage's approximation. Untouched while nothing has replaced the mesh yet.
const auto uv_now = [&]() {
return (model_uv.empty() || !changed) ? model_uv : transfer_uv_channel(base, model_uv, mesh.its);
};
if (!report(1))
return out;
@@ -5364,9 +5477,11 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
// Feature-adaptive: sample the combined displacement so refinement follows texture
// curvature. A null sampler (only LSCM layers, or nothing decodable) falls back to the
// length baseline alone.
const std::vector<Vec2f> uv_stage = uv_now();
const std::vector<Vec2f> *uv_ptr = uv_stage.empty() ? nullptr : &uv_stage;
HeightFieldSampler sampler;
if (params.subdiv_feature)
sampler = make_combined_displacement_sampler(mesh.its, layers, current);
sampler = make_combined_displacement_sampler(mesh.its, layers, current, uv_ptr);
// Colour boundaries need triangles of their own - the chord test cannot see them, since
// the height field is perfectly smooth across a change of filament.
ColorFieldSampler color;
@@ -5374,7 +5489,7 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
// boundaries are refined where the bake will actually change filament.
if (params.subdiv_color_edge_mm > 0.f && !color_settings.empty())
color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(color_settings.palette),
make_palette_quantizer(color_settings.palette_pure));
make_palette_quantizer(color_settings.palette_pure), uv_ptr);
// The refinement follows perceived colour: a mix is one colour here, however the slicer
// interleaves its filaments layer by layer.
// "Min edge" is a feature-mode control (it is the floor the curvature test refines down
@@ -5433,8 +5548,10 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
std::vector<uint8_t> cut_region(mesh.its.indices.size(), 0);
for (size_t i = 0; i < cut_region.size() && i < source.size(); ++i)
cut_region[i] = (region[size_t(source[i])] & REFINE_PAINTED) ? 1 : 0;
const std::vector<Vec2f> uv_refined = uv_now();
const HeightFieldSampler refined_sampler =
make_combined_displacement_sampler(mesh.its, layers, current);
make_combined_displacement_sampler(mesh.its, layers, current,
uv_refined.empty() ? nullptr : &uv_refined);
std::vector<int> cut_source;
size_t cuts = 0;
indexed_triangle_set cut = cut_mesh_at_steps(mesh.its, cut_region, refined_sampler,
@@ -5458,6 +5575,7 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
if (!report(100) || !changed)
return out; // an empty result: nothing to commit, which is not a failure
out.uv = uv_now();
out.mesh = std::move(mesh.its);
out.masks = std::move(current);
return out;
@@ -5747,10 +5865,16 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp
{
if (layer.empty() || !layer.color_enabled)
return nullptr;
if (m_color_tex && m_color_tex_source.lock() == layer.image_data && m_color_tex_smoothing == layer.smoothing)
const ColorAdjustKey adjust = ColorAdjustKey::of(layer);
if (m_color_tex && m_color_tex_source.lock() == layer.image_data && m_color_tex_smoothing == layer.smoothing &&
m_color_tex_adjust == adjust)
return m_color_tex.get();
std::unique_ptr<GLTexture> texture = upload_color_texture(decode_height_texture(layer), HEIGHT_TEX_MAX_PX);
// Corrected here rather than inside the decode, exactly as prepare_sampleable_layers() does for
// the bake, so the fast preview shades the image the bake will print.
DecodedHeightTexture tex = decode_height_texture(layer);
apply_color_adjustments(tex, layer);
std::unique_ptr<GLTexture> texture = upload_color_texture(tex, HEIGHT_TEX_MAX_PX);
if (!texture) {
m_color_tex.reset();
m_color_tex_source.reset();
@@ -5759,6 +5883,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp
m_color_tex = std::move(texture);
m_color_tex_source = layer.image_data;
m_color_tex_smoothing = layer.smoothing;
m_color_tex_adjust = adjust;
return m_color_tex.get();
}
@@ -5835,6 +5960,8 @@ static constexpr float STD_SUBDIV_BORDER_MM = 0.4f;
// what the eye actually lands on - a stepped outline around a printed decal reads as a defect in a
// way a slightly coarse relief transition does not - and it costs triangles along an outline only.
static constexpr float STD_SUBDIV_COLOR_MM = 0.25f;
// The Image tool's triangle budget, in thousands. See apply_image_mode_presets().
static constexpr int IMAGE_BUDGET_K = 1000;
bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
{
@@ -5865,6 +5992,61 @@ bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
return changed;
}
// Pinned into the volume rather than applied on the way to the bake, exactly as Standard mode pins
// displace_border and smooth_enabled above: the preview, the bake, the triangle estimate and the
// palette all read the layers straight off the volume, and a value that only some of them saw would
// put the preview and the bake out of step.
//
// The two tools share one layer stack - that is what "not a fork" means here - so opening the Image
// tool on a volume whose layers carry relief flattens them, and they stay flat when the displacement
// tool is reopened. Only that one number changes: the paint, the image, the placement and any relief
// already baked into the mesh are untouched.
bool GLGizmoTextureDisplacement::apply_image_mode_presets(ModelVolume *mv)
{
if (mv == nullptr)
return false;
bool changed = false;
TextureDisplacementOptions &opts = mv->texture_displacement_options;
// A million triangles for the picture. The auto budget is 750 k, chosen for relief, where the
// triangles buy shape; here they buy nothing but the sharpness of the edges between colours, and
// that is the whole output, so it is worth spending more of them on it.
if (opts.v2_max_triangles_k != IMAGE_BUDGET_K) {
opts.v2_max_triangles_k = IMAGE_BUDGET_K;
changed = true;
}
// Resolution is a slider in this panel rather than an Auto checkbox, so it needs a real value to
// start from: the same recommendation Auto would have produced for this model.
if (opts.v2_refine_mm <= 0.f) {
const float edge = v2_recommendation(*mv).edge_mm;
opts.v2_refine_mm = edge > 0.f ? edge : 0.3f;
changed = true;
}
for (TextureDisplacementLayer &layer : mv->texture_displacement_layers) {
// Zero depth is the whole of "no relief": the bake still refines the painted area and still
// writes the colours, every layer just contributes a displacement of 0 (see
// make_combined_displacement_sampler()). Nothing special-cases it.
if (layer.depth_mm != 0.f) {
layer.depth_mm = 0.f;
changed = true;
}
// Colour is the only thing an image bake produces, so it is on wherever there is colour to
// use rather than being a checkbox the user could leave off and wonder at.
const bool wanted = height_texture_has_color(layer);
if (layer.color_enabled != wanted) {
layer.color_enabled = wanted;
changed = true;
}
// Unwrap is the one projection the Image tool does not offer: it is an editor's worth of UI
// (seams, islands, the UV pane) for a case that wants a click and a slider. A layer arriving
// from the displacement tool set to it falls back to the triplanar default.
if (layer.projection_method == TextureProjectionMethod::LSCM) {
layer.projection_method = TextureProjectionMethod::Triplanar;
changed = true;
}
}
return changed;
}
void GLGizmoTextureDisplacement::bake_standard()
{
ModelVolume *mv = texture_volume();
@@ -6109,6 +6291,8 @@ void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareP
input.layers = mv->texture_displacement_layers;
input.params = params;
input.snapshot_name = snapshot_name;
if (mv->has_texture_uv())
input.model_uv = mv->texture_uv;
if (params.subdiv_color_edge_mm > 0.f)
input.color = color_settings_for(*mv);
@@ -6173,6 +6357,14 @@ void GLGizmoTextureDisplacement::render_paint_cursor_hint()
void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float bottom_limit)
{
// The Image tool gets its own, much smaller window (see GLGizmoImage.cpp): everything below this
// point is the displacement panel, and it is left exactly as it was rather than being threaded
// with tests for which of the two tools is drawing.
if (image_mode()) {
render_image_input_window(x, y, bottom_limit);
return;
}
ModelObject *mo = m_c->selection_info()->model_object();
if (!mo)
return;
@@ -6981,10 +7173,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad,
_L("Height of the relief: how far white in the texture lifts the surface, in "
"millimetres. Black does not move it at all, unless Midlevel says otherwise."));
// Mapped by the model's own UV layout there is no millimetre scale to set: the layout is the
// placement (see TextureProjectionMethod::ModelUV), so the slider would move nothing. The row
// states why rather than hiding: float_row()'s tooltip is shown for a disabled slider too.
const bool tile_size_na = layer.projection_method == TextureProjectionMethod::ModelUV;
m_imgui->disabled_begin(tile_size_na);
m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad,
_L("How wide one copy of the texture is on the model. Smaller repeats the pattern "
"more often and makes its detail finer; with Tile off, this is the size of the "
"single copy."));
tile_size_na ?
_L("Mapped by the model's own UV layout, which is already the placement - one "
"copy of the texture covers it exactly, so there is no tile size to set.") :
_L("How wide one copy of the texture is on the model. Smaller repeats the pattern "
"more often and makes its detail finer; with Tile off, this is the size of the "
"single copy."));
m_imgui->disabled_end();
m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad,
_L("Turns the texture on the surface, in degrees - for lining a pattern up with an "
"edge of the model."));
@@ -7090,7 +7291,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
}
// Mapping, as five icons in TextureProjectionMethod's own order.
// Mapping, as one icon per TextureProjectionMethod, in the enum's own order.
{
const float x0 = ImGui::GetCursorPosX();
ImGui::AlignTextToFramePadding();
@@ -7118,13 +7319,26 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
{ "texture_displacement_map_view.svg", _L("From view"),
_L("From view - projects straight onto the painted area from where you are looking, like a slide "
"projector.") },
{ "texture_displacement_map_model_uv.svg", _L("Model UV"),
_L("Model UV - uses the texture coordinates the model itself came with, so a texture made for "
"that model lands exactly where it was painted. Nothing is unwrapped, and Tile size does "
"not apply: the model's own layout is the placement.") },
};
// Only a volume that actually carries a UV channel can be mapped by it. Shown
// greyed out rather than hidden, so the reason is visible instead of the control
// simply being missing.
const wxString model_uv_na =
(mv != nullptr && mv->has_texture_uv()) ?
wxString() :
_L("This model carries no texture coordinates of its own. Only a model imported with a UV "
"channel - a textured OBJ, say - can be mapped by them.");
for (int mi = 0; mi < int(IM_ARRAYSIZE(mappings)); ++mi) {
if (mi > 0)
ImGui::SameLine(0.f, gap_s);
const auto method = static_cast<TextureProjectionMethod>(mi);
const wxString na = (method == TextureProjectionMethod::ModelUV) ? model_uv_na : wxString();
if (icon_toggle(830 + mi, mappings[mi].file, layer.projection_method == method, icon_sm, mappings[mi].label,
mappings[mi].tip) &&
mappings[mi].tip, na) &&
layer.projection_method != method) {
// Capture the current view the moment "From view" is chosen, so it does something
// sensible immediately rather than projecting from a stale/default direction.
@@ -7254,18 +7468,33 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
const std::string blend_add = _u8L_CONTEXT("Add", "Texture Displacement");
const std::string blend_override = _u8L_CONTEXT("Override", "Texture Displacement");
const std::string blend_subtract = _u8L_CONTEXT("Subtract", "Texture Displacement");
const std::string blend_multiply = _u8L_CONTEXT("Multiply", "Texture Displacement");
const std::string blend_divide = _u8L("Divide");
const char *blend_items[] = { blend_add.c_str(), blend_subtract.c_str(), blend_multiply.c_str(), blend_divide.c_str() };
const char *blend_items[] = { blend_add.c_str(), blend_override.c_str(), blend_subtract.c_str(),
blend_multiply.c_str(), blend_divide.c_str() };
// The combo's order is the panel's, not the enum's - Override reads as the sibling of Add and
// belongs next to it, while TextureBlendMode's numbering is fixed by what projects already
// store. Hence the explicit mapping rather than a cast of the index.
static const TextureBlendMode blend_modes[] = { TextureBlendMode::Add, TextureBlendMode::Override,
TextureBlendMode::Subtract, TextureBlendMode::Multiply,
TextureBlendMode::Divide };
static_assert(IM_ARRAYSIZE(blend_items) == IM_ARRAYSIZE(blend_modes),
"every Blend entry needs its mode");
// The first layer has nothing before it to combine with - build_texture_displacement() makes it
// add regardless - so its combo shows Add and cannot be changed.
const bool base_layer = li == 0;
int blend_mode = base_layer ? 0 : static_cast<int>(layer.blend_mode);
int blend_mode = 0;
if (!base_layer)
for (int bi = 0; bi < int(IM_ARRAYSIZE(blend_modes)); ++bi)
if (blend_modes[bi] == layer.blend_mode)
blend_mode = bi;
ImGui::SetNextItemWidth(-(card_pad + gap_s + icon_sm));
m_imgui->disabled_begin(base_layer);
if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer) {
layer.blend_mode = static_cast<TextureBlendMode>(blend_mode);
if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer &&
blend_mode >= 0 && blend_mode < int(IM_ARRAYSIZE(blend_modes))) {
layer.blend_mode = blend_modes[blend_mode];
m_preview_params_dirty = true;
}
m_imgui->disabled_end();
@@ -7273,10 +7502,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->tooltip(base_layer ?
_u8L("The base layer has nothing beneath it to combine with, so it always adds.") :
_u8L("What this layer does where it overlaps the ones below. Add and "
"Subtract pile relief on or carve it away. Multiply and Divide scale "
"what is underneath, which turns this layer into a mask over it - "
"there, Depth acts as a strength, and 1 mm leaves white areas "
"untouched."),
"Subtract pile relief on or carve it away. Override replaces them, "
"so only this layer's relief is left where it is painted. Multiply "
"and Divide scale what is underneath, which turns this layer into a "
"mask over it - there, Depth acts as a strength, and 1 mm leaves "
"white areas untouched."),
wrap_w);
ImGui::SameLine(0.f, gap_s);
@@ -62,6 +62,12 @@ public:
static TextureDisplacementPrepareResult prepare_mesh(const indexed_triangle_set &base,
const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers,
// `base`'s own UV channel, when it has one
// (ModelVolume::texture_uv). Resampled onto
// the result and reported back in it, and fed
// to the samplers so a ModelUV layer drives
// the refinement through its own layout.
const std::vector<Vec2f> &model_uv,
const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings,
const DisplacementProgressFn &progress,
@@ -155,6 +161,33 @@ public:
// What the cache was built from.
std::vector<ColorRGBA> m_palette_filaments;
std::vector<std::shared_ptr<std::vector<unsigned char>>> m_palette_images; // the colouring layers' images
// One layer's colour correction, as a value that can be compared and cached on. It belongs to
// every cache keyed on "what the image looks like", because a correction slider changes the
// pixels while leaving the image_data pointer alone.
struct ColorAdjustKey
{
float brightness = 0.f, contrast = 0.f, saturation = 1.f, gamma = 1.f, hue = 0.f;
float sharpness = 0.f, bg_tolerance = 0.15f;
float bg_r = 1.f, bg_g = 1.f, bg_b = 1.f;
int color_count = 0;
bool invert = false, bg_remove = false, bg_from_corners = true;
bool operator==(const ColorAdjustKey &o) const
{
return brightness == o.brightness && contrast == o.contrast && saturation == o.saturation &&
gamma == o.gamma && hue == o.hue && sharpness == o.sharpness &&
bg_tolerance == o.bg_tolerance && bg_r == o.bg_r && bg_g == o.bg_g && bg_b == o.bg_b &&
color_count == o.color_count && invert == o.invert && bg_remove == o.bg_remove &&
bg_from_corners == o.bg_from_corners;
}
bool operator!=(const ColorAdjustKey &o) const { return !(*this == o); }
static ColorAdjustKey of(const TextureDisplacementLayer &l)
{
return { l.brightness, l.contrast, l.saturation, l.gamma, l.hue,
l.sharpness, l.bg_tolerance, l.bg_color[0], l.bg_color[1], l.bg_color[2],
l.color_count, l.color_invert, l.bg_remove, l.bg_from_corners };
}
};
std::vector<ColorAdjustKey> m_palette_adjusts; // alongside m_palette_images, one per entry
std::optional<std::vector<PaletteEntry>> m_mix_ranking; // rank_mixes(), computed on demand
bool m_palette_mixing = false;
int m_palette_mix_count = 0;
@@ -225,7 +258,25 @@ public:
// rotation handles instead of painting); otherwise forwards to the normal painting handling.
bool on_mouse(const wxMouseEvent &mouse_event) override;
// Loads `path` as the Image tool's picture, importing it into the user's library on the way so
// it is there next time. For a drop onto the 3D view; see PlaterDropTarget::OnDropFiles(). False
// when this is not the Image tool, or the file could not be read.
bool drop_image_file(const std::string &path);
// Called while a file is being dragged over the 3D view, so the Image tool can show that
// dropping it there will do something. Without it the drag has no feedback at all and the only
// way to find out is to let go.
void set_file_drag_over(bool over);
protected:
// True in the Image tool (see GLGizmoImage): this same gizmo restricted to the one case where
// the surface does not move at all - a colour picture laid on the model. Everything behind the
// panel is shared and unchanged: the same layers, the same paint masks, the same bake. This flag
// only tells the panel which of the two tools it is drawing, and makes it pin the values the
// restricted case fixes (see apply_image_mode_presets()).
virtual bool image_mode() const { return false; }
bool on_init() override;
void on_render_input_window(float x, float y, float bottom_limit) override;
std::string on_get_name() const override;
// Never in the assemble view: its toolbar does not offer this gizmo, and every preview here is drawn
@@ -243,8 +294,11 @@ protected:
EnforcerBlockerType get_left_button_state_type() const override { return m_erase_mode ? EnforcerBlockerType::NONE : EnforcerBlockerType::ENFORCER; }
EnforcerBlockerType get_right_button_state_type() const override { return m_erase_mode ? EnforcerBlockerType::ENFORCER : EnforcerBlockerType::NONE; }
// Paint / Erase, from the panel. See get_left_button_state_type(). Protected because a subclass
// overriding handle_snapshot_action_name() has to read the same flag to name the undo step.
bool m_erase_mode = false;
private:
bool on_init() override;
void update_model_object() override;
void update_from_model_object(bool first_update) override;
void on_opening() override {}
@@ -284,14 +338,44 @@ private:
// Standard is active so what Preview shows is always what Bake will do. Returns true if it actually
// changed something, so the caller can invalidate the preview.
bool apply_standard_mode_presets(ModelVolume *mv);
// Copies the panel's own state (active layer, Standard/Pro) onto the volume, so the project file
// can carry it. Cheap and idempotent - called wherever either of the two changes.
void store_panel_state();
// Standard mode's Bake: remesh to an even density, refine where the texture bends, then displace.
// The order matters and is the whole reason this is one button - a height map can only move
// existing vertices, so the mesh has to be prepared first, and remeshing after painting would drop
// the paint if it were not carried across (see prepare_mesh()).
void bake_standard();
// Copies the panel's own state (active layer, Standard/Pro) onto the volume, so the project file
// can carry it. Cheap and idempotent - called wherever either of the two changes.
void store_panel_state();
// Image mode's fixed values, on top of Standard's and pinned the same way (every frame, so the
// preview cannot disagree with the bake): zero depth on every layer, and colour on wherever the
// picture has any. Those two are what turn the displacement bake into "put this picture on the
// model" without a second code path. Returns true if it actually changed something.
bool apply_image_mode_presets(ModelVolume *mv);
// The Image tool's whole window: load a picture, click the model to place it, correct it, Apply.
// Defined in GLGizmoImage.cpp, beside the class it exists for, rather than here - it is a member
// only so that it can reach the same private machinery the displacement panel uses, and keeping
// it out of this file leaves that panel's 1700 lines alone.
void render_image_input_window(float x, float y, float bottom_limit);
// Set once the Image tool has switched its placement handle on for the current picture, so that
// turning the handle back off stays off instead of being re-armed on the next frame.
bool m_image_handle_armed = false;
// Set between a drag entering the 3D view and leaving or dropping on it; drives the drop zone
// render_image_input_window() draws over the canvas.
bool m_file_drag_over = false;
void render_image_drop_hint();
// Set while the panel's background dropper is armed: the next click on the picture preview takes
// the colour under it instead of opening the library.
bool m_image_bg_picking = false;
// Takes the background colour from the point of the model under `mouse_pos`: the picture's own
// colour there, read through the same projection the bake uses, not the lit pixel on screen.
// False when the ray misses or the layer does not colour that point, leaving the dropper armed.
bool pick_background_at(const Vec2d &mouse_pos);
// Which of the Image panel's sections are unfolded. Panel state for the session, deliberately
// not saved with the project.
bool m_image_sec_place = true;
bool m_image_sec_color = true;
bool m_image_sec_bg = true;
// Queues one prepare_mesh() run on the job worker and commits its result when it lands. Every
// mesh-preparation button goes through here - Pro's Remesh, Pro's adaptive Subdivide and Standard's
@@ -424,8 +508,6 @@ private:
void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry);
void import_custom_texture(TextureDisplacementLayer &layer);
// Paint / Erase, from the panel. See get_left_button_state_type().
bool m_erase_mode = false;
// Per slot: whether the layer's card shows every setting or only Depth, Tile size and Rotation.
// Panel state only, not saved with the project.
std::array<bool, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_layer_expanded{};
@@ -921,18 +1003,31 @@ private:
// Merges chart `b`'s join group into chart `a`'s (materialising `groups` to `chart_count` first).
static void join_island_groups(std::vector<int> &groups, int a, int b, int chart_count);
// Final per-vertex texture uv for the projections the shader can't reconstruct itself - LSCM (an
// unwrap) and ViewProjected (a projector plane the shader doesn't know). One entry per patch/base
// vertex, already through apply_uv_transform(). Empty for Triplanar/Cylindrical/Spherical, which
// the shader projects on its own. Shared by the shaded preview and the UV-check overlay.
// unwrap), ModelUV (the model's own channel) and ViewProjected (a projector plane the shader
// doesn't know). One entry per patch/base vertex, already through apply_uv_transform(). Empty for
// Triplanar/Cylindrical/Spherical, which the shader projects on its own. Shared by the shaded
// preview and the UV-check overlay.
//
// `patch_source` maps each patch triangle back to its base-mesh triangle
// (TriangleSelector::get_facets_strict()'s out_source). Only ModelUV needs it - its channel is per
// base triangle - and without it a ModelUV layer reports no uvs and the preview falls back.
std::vector<Vec2f> compute_layer_vertex_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer) const;
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source = nullptr) const;
// The same, but three UVs per patch triangle (corner 0..2 of triangle i at 3i..3i+2). This is what
// the flat, unshared-vertex preview meshes actually want: under LSCM a seam vertex has a different
// UV in each island it borders, so collapsing to one per vertex handed a triangle at an unjoined
// seam its neighbour's placement - one visibly skewed triangle per face. Every other projection is
// single-valued per point, so there a corner's UV is just its vertex's.
std::vector<Vec2f> compute_layer_corner_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer) const;
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source = nullptr) const;
// The model's own channel over `local_patch`, already through apply_uv_transform() - three per
// patch triangle. Empty when the volume has no channel, or when `patch_source` is missing. Shared
// by the two functions above so the per-vertex and per-corner forms can never disagree.
std::vector<Vec2f> model_uv_corners(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const;
// The in-shader projection for `layer` (0 Triplanar, 1 Cylindrical, 2 Spherical) plus, for the two
// wrapping ones, the patch centroid and cylinder axis they wrap around - in the texture frame the
// shaders project in. Taken from the bake's own texture_displacement_patch_frame(), so a preview
@@ -1039,6 +1134,7 @@ private:
std::unique_ptr<GLTexture> m_color_tex;
std::weak_ptr<const std::vector<unsigned char>> m_color_tex_source;
float m_color_tex_smoothing = -1.f;
ColorAdjustKey m_color_tex_adjust;
// Library textures the picker has shown at least once, keyed by file path (see LibraryTexture).
std::map<std::string, LibraryTexture> m_library_textures;
+13
View File
@@ -22,6 +22,7 @@
//#include "slic3r/GUI/Gizmos/GLGizmoHollow.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoSeam.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoImage.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoSimplify.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoEmboss.hpp"
@@ -201,6 +202,11 @@ void GLGizmosManager::switch_gizmos_icon_filename()
// light/dark switch.
gizmo->set_icon_filename("toolbar_texture_displacement.svg");
break;
case(EType::Image):
// Drawn flat in Orca's menu-icon palette, which reads on either background, so one file
// serves both themes here too.
gizmo->set_icon_filename("toolbar_image.svg");
break;
case(EType::MeshBoolean):
gizmo->set_icon_filename(m_is_dark ? "toolbar_meshboolean_dark.svg" : "toolbar_meshboolean.svg");
break;
@@ -252,6 +258,8 @@ bool GLGizmosManager::init()
m_gizmos.emplace_back(new GLGizmoMmuSegmentation(m_parent, m_is_dark ? "mmu_segmentation_dark.svg" : "mmu_segmentation.svg", EType::MmSegmentation));
// One shared icon (no dedicated dark variant yet); it recolours acceptably in both themes.
m_gizmos.emplace_back(new GLGizmoTextureDisplacement(m_parent, "toolbar_texture_displacement.svg", EType::TextureDisplacement));
// The same gizmo restricted to the zero-displacement case; see GLGizmoImage.
m_gizmos.emplace_back(new GLGizmoImage(m_parent, "toolbar_image.svg", EType::Image));
m_gizmos.emplace_back(new GLGizmoEmboss(m_parent, m_is_dark ? "toolbar_text_dark.svg" : "toolbar_text.svg", EType::Emboss));
m_gizmos.emplace_back(new GLGizmoSVG(m_parent));
m_gizmos.emplace_back(new GLGizmoMeasure(m_parent, m_is_dark ? "toolbar_measure_dark.svg" : "toolbar_measure.svg", EType::Measure));
@@ -591,6 +599,8 @@ bool GLGizmosManager::gizmo_event(SLAGizmoEventType action, const Vec2d& mouse_p
return dynamic_cast<GLGizmoFuzzySkin*>(m_gizmos[FuzzySkin].get())->gizmo_event(action, mouse_position, shift_down, alt_down, control_down);
else if (m_current == TextureDisplacement)
return dynamic_cast<GLGizmoTextureDisplacement*>(m_gizmos[TextureDisplacement].get())->gizmo_event(action, mouse_position, shift_down, alt_down, control_down);
else if (m_current == Image)
return dynamic_cast<GLGizmoImage*>(m_gizmos[Image].get())->gizmo_event(action, mouse_position, shift_down, alt_down, control_down);
else if (m_current == MeshBoolean)
return dynamic_cast<GLGizmoMeshBoolean*>(m_gizmos[MeshBoolean].get())->gizmo_event(action, mouse_position, shift_down, alt_down, control_down);
else if (m_current == BrimEars)
@@ -605,6 +615,7 @@ bool GLGizmosManager::is_paint_gizmo()
m_current == EType::MmSegmentation ||
m_current == EType::FuzzySkin ||
m_current == EType::TextureDisplacement ||
m_current == EType::Image ||
m_current == EType::Seam;
}
@@ -1558,6 +1569,8 @@ std::string get_name_from_gizmo_etype(GLGizmosManager::EType type)
return "Fuzzy Skin Painting";
case GLGizmosManager::EType::TextureDisplacement:
return "Texture Displacement";
case GLGizmosManager::EType::Image:
return "Image";
default:
return "";
}
@@ -96,6 +96,7 @@ public:
FuzzySkin,
MmSegmentation,
TextureDisplacement,
Image,
Emboss,
Svg,
Measure,
@@ -8,6 +8,7 @@
#include "libslic3r/TextureDisplacement.hpp"
#include <exception>
#include <cstddef>
#include <cstdint>
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <memory>
@@ -26,6 +27,7 @@
#include "slic3r/Utils/UndoRedo.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <array>
namespace Slic3r::GUI {
@@ -74,7 +76,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
}
return true;
},
color, m_input.volume_to_world, nullptr, &m_stats));
color, m_input.volume_to_world, nullptr, &m_stats,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv));
// Always finish at 100: this is what closes the notification. Reported even on cancel, where
// build_texture_displacement() returns an empty mesh and finalize() commits nothing.
@@ -126,16 +129,71 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
{ mix.a_percent(), 100 - mix.a_percent() });
});
// Whether the bake replaced the triangles or only moved the vertices it was given. The classic
// path is topology-preserving by contract, so every per-triangle annotation on the volume still
// means what it did; the default pipeline refines, cuts and simplifies, so none of them do.
// Both are checked: the flag is what the bake promised, the count is what it delivered. Only
// the triangles matter - the annotations are per facet and reference no vertex index - so a
// vertex count that changed on its own (the bake compacts unreferenced vertices away) is not a
// reason to remap anything.
const bool topology_changed = m_input.options.pipeline_v2 ||
m_result.its.indices.size() != m_input.base_mesh.indices.size();
// Carry the volume's existing paint - the model's own colours above all - across that rebuild.
// Without it a bake left the mmu_segmentation_facets bitstream pointing at a triangle set that
// no longer existed, which is what scrambled the colours of an imported coloured model even
// with colour switched off on every layer and nothing in the bake that was meant to touch them.
//
// transfer_facet_paint() rather than ModelVolume::restore_painting(): the latter's remap wants
// a target triangle overlapping the source paint to within 0.01 mm, which the baked surface is
// nowhere near - it has moved by the relief depth. Nothing to do when the topology is intact:
// triangle i is still triangle i, so the paint is already exactly right and carrying it at all
// could only lose detail.
std::array<TriangleSelector::TriangleSplittingData, 4> before;
if (topology_changed) {
before[0] = volume->supported_facets.get_data();
before[1] = volume->seam_facets.get_data();
before[2] = volume->mmu_segmentation_facets.get_data();
before[3] = volume->fuzzy_skin_facets.get_data();
}
volume->set_mesh(std::move(m_result));
volume->set_new_unique_id();
volume->calculate_convex_hull();
if (topology_changed) {
const std::array<FacetsAnnotation *, 4> target{ &volume->supported_facets, &volume->seam_facets,
&volume->mmu_segmentation_facets,
&volume->fuzzy_skin_facets };
for (size_t ch = 0; ch < target.size(); ++ch) {
const std::vector<uint8_t> state =
transfer_facet_paint(m_input.base_mesh, before[ch], volume->mesh().its);
// Nothing was painted in this channel, or there is nothing to paint it onto: either
// way the annotation must not keep describing the mesh that is gone.
if (state.empty()) {
target[ch]->reset();
continue;
}
TriangleSelector selector(volume->mesh());
bool any = false;
for (size_t i = 0; i < state.size(); ++i)
if (state[i] != 0) {
selector.set_facet(int(i), EnforcerBlockerType(state[i]));
any = true;
}
if (any)
target[ch]->set(selector);
else
target[ch]->reset();
}
}
// Colour lands in mmu_segmentation_facets, merged *over* whatever is already painted there
// rather than replacing it: a triangle the texture does not colour keeps its existing filament,
// and one the user never painted at all stays at NONE, which already means "the volume's own
// filament". That is what confines the effect to the painted area without having to invent a
// colour for everything outside it. Safe to index straight onto the new mesh - the bake is
// topology-preserving, so triangle i is still triangle i.
// colour for everything outside it. What was already painted is whatever the transfer above
// left in the annotation, so this bake's colour lands on top of it rather than instead of it.
if (!m_triangle_color.empty() && m_triangle_color.size() == volume->mesh().its.indices.size()) {
TriangleSelector selector(volume->mesh());
const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data();
@@ -159,7 +217,6 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
// pipeline, which rebuilds and then simplifies the mesh. A mask left behind against the old
// topology is exactly what makes the gizmo reload an empty selector over a non-empty mask and
// then erase it on the next flush - see GLGizmoTextureDisplacement::update_from_model_object().
const bool topology_changed = m_input.base_mesh.indices.size() != volume->mesh().its.indices.size();
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const auto it = std::find_if(m_input.layers.begin(), m_input.layers.end(),
[slot](const TextureDisplacementLayer &l) { return l.slot == slot; });
@@ -167,6 +224,14 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
volume->texture_displacement_facet(slot).reset();
}
// The model's own UV channel. Resampled onto a rebuilt mesh from the surface the bake started
// from: a displaced vertex came from its foot point on that surface, which is the position its
// uv belongs to - so no distance limit, the relief depth is exactly how far it has moved.
if (!m_input.model_uv.empty())
volume->texture_uv = topology_changed ? transfer_uv_channel(m_input.base_mesh, m_input.model_uv,
volume->mesh().its) :
m_input.model_uv;
ModelObject *object = volume->get_object();
if (object == nullptr)
return;
@@ -219,6 +284,8 @@ void queue_texture_displacement_bake(const ModelVolume &volume, const TextureCol
input.take_snapshot = take_snapshot;
input.volume_id = volume.id();
input.base_mesh = volume.mesh().its;
if (volume.has_texture_uv())
input.model_uv = volume.texture_uv;
input.layers = volume.texture_displacement_layers;
input.options = volume.texture_displacement_options;
input.volume_to_world = texture_displacement_volume_to_world(volume);
@@ -34,6 +34,9 @@ struct TextureDisplacementBakeInput
// writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU
// paint gizmo writes - alongside the displaced geometry.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), empty when it has none: what a ModelUV
// layer maps through, and what the commit puts back on the baked mesh.
std::vector<Vec2f> model_uv;
// Whether this job pushes its own undo step when it commits. False when the caller has already
// taken one that is meant to cover the displacement as well - Standard mode's Bake, which remeshes
// and subdivides first and has to undo as a single action.
@@ -17,6 +17,7 @@
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <memory>
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TriangleMesh.hpp"
namespace Slic3r::GUI {
@@ -52,13 +53,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
indexed_triangle_set mesh = m_input.base_mesh;
TextureDisplacementFacetsData masks = m_input.facets_data;
std::vector<Vec2f> uv = m_input.model_uv;
// The default pipeline can only move vertices the mesh already has, so its recipe starts with the
// preparation - which is where "first it remeshes, then it refines" actually happens. The
// experimental pipeline refines as part of the bake and has nothing to prepare.
if (m_input.run_prepare && !m_input.options.pipeline_v2) {
const TextureDisplacementPrepareResult prepared =
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params,
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, uv, m_input.prepare_params,
m_input.color,
// Preparation is roughly half the run; the bake
// takes the progress bar from there.
@@ -77,12 +79,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
if (!prepared.mesh.indices.empty()) {
mesh = prepared.mesh;
masks = prepared.masks;
uv = prepared.uv; // resampled onto the prepared mesh, as the prepare commit stores it
}
}
build_texture_displacement(mesh, m_input.layers, masks, m_input.options,
[&report](int pct) { return report(50 + pct / 2); },
/* color */ nullptr, m_input.volume_to_world, &recorder);
/* color */ nullptr, m_input.volume_to_world, &recorder, /* stats */ nullptr,
uv.empty() ? nullptr : &uv);
m_stages = recorder.take();
ctl.update_status(100, status); // always finish at 100: this is what closes the notification
@@ -115,6 +119,8 @@ void queue_texture_displacement_debug(const ModelVolume &volume, const TextureCo
input.check_topology = check_topology;
input.color = color;
input.volume_to_world = texture_displacement_volume_to_world(volume);
if (volume.has_texture_uv())
input.model_uv = volume.texture_uv;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();
@@ -47,6 +47,9 @@ struct TextureDisplacementDebugInput
// Only the quantizer is used, and only by the refinement's colour criterion - the debug run
// produces no colour of its own.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), so the recipe captured here is the one a
// ModelUV layer actually bakes through. Empty when the volume has none.
std::vector<Vec2f> model_uv;
// The edge scan behind the open / non-manifold counts is a sort over every half-edge, which on a
// multi-million triangle stage costs more than the stage did. On by default because a stage that
@@ -46,7 +46,7 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl)
// idle loop.
int last_reported = 1;
m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers,
m_input.params, m_input.color,
m_input.model_uv, m_input.params, m_input.color,
[&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled())
return false;
@@ -106,6 +106,10 @@ void TextureDisplacementPrepareJob::finalize(bool canceled, std::exception_ptr &
volume->restore_painting(saved_painting);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
volume->texture_displacement_facet(i).set_data(std::move(m_result.masks[size_t(i)]));
// The model's own UV channel, resampled onto the new mesh by the run. Also put back after
// restore_painting(), which resets it along with the extra facets for the same reason: it is
// indexed by triangle and the triangles have changed.
volume->texture_uv = std::move(m_result.uv);
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
@@ -9,6 +9,7 @@
#include "Job.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TextureDisplacement.hpp"
namespace Slic3r {
@@ -47,6 +48,10 @@ struct TextureDisplacementPrepareInput
// skips the colour criterion entirely. Only the *quantizer* is used here: refinement follows
// perceived colour, never the interleaving that realises a mix.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), empty when it has none. prepare_mesh()
// resamples it onto the mesh it produces, and the commit puts it back - like the masks, it is
// indexed by triangle and so cannot survive the stages on its own.
std::vector<Vec2f> model_uv;
// The undo step the commit opens. Standard mode's Bake names it after the bake, because the
// displacement job that follows commits into this same step rather than pushing its own.
std::string snapshot_name;
@@ -48,7 +48,8 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
(!m_current_generation ||
m_current_generation->load() == m_generation);
},
color, m_input.volume_to_world);
color, m_input.volume_to_world, nullptr, nullptr,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv);
}
void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr)
@@ -29,6 +29,9 @@ struct TextureDisplacementPreviewInput
// Empty unless a layer is colouring, in which case the preview reports the palette entry per
// triangle alongside the mesh, so the Normal view shows the colours the bake will produce.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), so a ModelUV layer previews through the
// same layout it will bake through. Empty when the volume has none.
std::vector<Vec2f> model_uv;
};
// A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per
+59
View File
@@ -67,6 +67,7 @@
#include "slic3r/GUI/Field.hpp"
#include "slic3r/GUI/Widgets/PopupWindow.hpp"
#include <set>
#include <wx/filename.h>
#include "slic3r/GUI/Widgets/StaticLine.hpp"
#include "slic3r/GUI/GUI_ObjectSettings.hpp"
#include "slic3r/GUI/ColorDecomposeDialog.hpp"
@@ -246,6 +247,7 @@
#include "DeviceCore/DevNozzleSystem.h" // DevNozzle, GetExtNozzles / GetRackNozzles
#include "ProjectDirtyStateManager.hpp"
#include "Gizmos/GLGizmoSimplify.hpp" // create suggestion notification
#include "Gizmos/GLGizmoImage.hpp" // Drop an image onto the Image tool
#include "Gizmos/GLGizmoSVG.hpp" // Drop SVG file
#include "Gizmos/GizmoObjectManipulation.hpp"
@@ -7110,6 +7112,9 @@ void Sidebar::can_search()
p->can_search();
}
// Defined with the other drop helpers below; forward declared because the drop target uses it.
void set_image_drag_over(Plater &plater, bool over);
class PlaterDropTarget : public wxFileDropTarget
{
public:
@@ -7119,12 +7124,53 @@ public:
virtual bool OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &filenames);
// A drag has no feedback of its own, so the Image tool is told when one is over the view and
// draws a drop zone for as long as it is. wxWidgets cannot hand us the file names before the
// drop on GTK, so this says nothing about what is being dragged: the zone invites a picture,
// and anything else still falls through to the model loader.
virtual wxDragResult OnEnter(wxCoord x, wxCoord y, wxDragResult def)
{
set_image_drag_over(m_plater, true);
return wxFileDropTarget::OnEnter(x, y, def);
}
virtual void OnLeave()
{
set_image_drag_over(m_plater, false);
wxFileDropTarget::OnLeave();
}
private:
MainFrame& m_mainframe;
Plater& m_plater;
};
namespace {
// The Image gizmo, but only while it is the open one. Null otherwise, which is what keeps a drag or
// a drop from meaning anything different with the tool closed.
GLGizmoImage *open_image_gizmo(Plater &plater)
{
GLCanvas3D *canvas = plater.canvas3D();
if (canvas == nullptr)
return nullptr;
GLGizmosManager &gizmos = canvas->get_gizmos_manager();
if (gizmos.get_current_type() != GLGizmosManager::Image)
return nullptr;
return dynamic_cast<GLGizmoImage *>(gizmos.get_gizmo(GLGizmosManager::Image));
}
// True when `file` is a picture and the Image gizmo is the one currently open, in which case it has
// taken the file. Mirrors emboss_svg() below, which does the same for an SVG and the text tool.
bool drop_image_on_gizmo(Plater &plater, const wxString &file)
{
static const std::set<wxString> image_ext = { "png", "jpg", "jpeg", "bmp" };
const wxString ext = wxFileName(file).GetExt().Lower();
if (image_ext.count(ext) == 0)
return false;
GLGizmoImage *image = open_image_gizmo(plater);
return image != nullptr && image->drop_image_file(into_u8(file));
}
bool emboss_svg(Plater& plater, const wxString &svg_file, const Vec2d& mouse_drop_position)
{
std::string svg_file_str = into_u8(svg_file);
@@ -7783,6 +7829,12 @@ const std::regex Plater::priv::pattern_zip_amf(".*[.]zip[.]amf", std::regex::ica
const std::regex Plater::priv::pattern_any_amf(".*[.](amf|amf[.]xml|zip[.]amf)", std::regex::icase);
const std::regex Plater::priv::pattern_prusa(".*bbl", std::regex::icase);
void set_image_drag_over(Plater &plater, bool over)
{
if (GLGizmoImage *image = open_image_gizmo(plater))
image->set_file_drag_over(over);
}
bool PlaterDropTarget::OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &filenames)
{
#ifdef WIN32
@@ -7790,11 +7842,18 @@ bool PlaterDropTarget::OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &fi
this->MSWUpdateDragImageOnLeave();
#endif // WIN32
set_image_drag_over(m_plater, false); // the drag is over, whoever ends up taking the file
m_mainframe.Raise();
m_mainframe.select_tab(TAB_ID_PREPARE);
if (wxGetApp().is_editor())
m_plater.select_view_3D("3D");
// A picture dropped while the Image tool is open is that tool's input, not a model to load. Only
// then: with the tool closed a dropped PNG goes on doing whatever it did before.
if (filenames.size() == 1 && drop_image_on_gizmo(m_plater, filenames.Last()))
return true;
// When only one .svg file is dropped on scene
if (filenames.size() == 1) {
const wxString &filename = filenames.Last();
+3
View File
@@ -114,6 +114,9 @@ constexpr std::array<ShortcutInfo, size_t(Shortcut::Count)> shortcut_table = {{
SHORTCUT(GizmoAssembly, "gizmo_assembly", L("Gizmo assemble"), PLATER, { 'Y' }),
SHORTCUT(GizmoBrimEars, "gizmo_brim_ears", L("Gizmo brim ears"), PLATER, { 'E' }),
SHORTCUT(GizmoDisplacement, "gizmo_displacement", L("Gizmo texture displacement painting"), PLATER, { 'D' }),
// Unbound by default: every single letter this context can reach is already taken, and a default
// that shadows one of them is worse than none. The shortcuts dialog lists it for binding.
SHORTCUT(GizmoImage, "gizmo_image", L("Gizmo image painting"), PLATER, KeyChord{}),
// Sliders
SHORTCUT(GoToLayer, "go_to_layer", L("Jump to layer"), PREVIEW, { 'G', SHIFT }),
+1 -1
View File
@@ -38,7 +38,7 @@ enum class Shortcut : uint8_t {
MoveSelectionLeft, MoveSelectionRight, MoveSelectionUp, MoveSelectionDown, RotateSelectionLeft, RotateSelectionRight,
// Gizmos
GizmoMove, GizmoRotate, GizmoScale, GizmoFlatten, GizmoCut, GizmoMeshBoolean, GizmoFdmSupports, GizmoSeam, GizmoFuzzySkin,
GizmoMmuSegmentation, GizmoEmboss, GizmoMeasure, GizmoAssembly, GizmoBrimEars, GizmoDisplacement,
GizmoMmuSegmentation, GizmoEmboss, GizmoMeasure, GizmoAssembly, GizmoBrimEars, GizmoDisplacement, GizmoImage,
// Sliders
GoToLayer, LayerSliderUp, LayerSliderDown, MovesSliderLeft, MovesSliderRight, MovesSliderStart, MovesSliderEnd,
// Painting tools
@@ -37,10 +37,12 @@
#include <boost/filesystem.hpp>
#include "libslic3r/AABBMesh.hpp"
#include "libslic3r/AABBTreeIndirect.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TextureBake/TextureBakeDecimate.hpp"
#include "libslic3r/TextureBake/TextureBakeFlip.hpp"
#include "libslic3r/TextureBake/TextureBakeMesh.hpp"
#include "libslic3r/TextureBake/TextureBakeRepair.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/PNGReadWrite.hpp"
@@ -2708,6 +2710,157 @@ TEST_CASE("Cancelling while flat faces are harvested past the budget stops the d
CHECK(canceled > full);
}
TEST_CASE("Baking twice keeps the mesh closed and carries the first bake's colours across",
"[TextureDisplacement][Regression]")
{
// Bake, colour part of what came back, bake again. The one-run pipeline rebuilds the topology, so
// the colour recorded against the first bake's mesh indexes triangles that no longer mean the same
// thing. Taken across by triangle id it lands on unrelated faces - a bake almost always grows the
// triangle count, so every stale id passes the only bound check deserialize() makes - which both
// scatters colour over surfaces nothing painted and destroys what the first pass laid down.
// transfer_facet_paint() carries it across by position instead - every triangle of the new mesh
// takes the state painted at its foot point on the old one - which is what makes successive bakes
// accumulate. See TextureDisplacementBakeJob, which is where the two meet.
constexpr float side = 20.f;
constexpr float refine = 0.5f;
constexpr float radius = 4.f;
const indexed_triangle_set cube = its_make_cube(side, side, side);
const Vec2f first_stroke(6.f, 6.f), second_stroke(14.f, 14.f);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.image_data = make_rgb_png_2x2();
layer.tiling_scale = 5.f;
layer.depth_mm = 0.5f;
layer.color_enabled = true;
TextureDisplacementOptions options;
options.pipeline_v2 = true;
options.v2_refine_mm = refine;
options.v2_max_triangles_k = 750;
// Three primaries, so a palette entry is legible in the assertions below.
const std::array<Vec3f, 3> palette = { Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(0, 0, 1) };
TextureColorRequest request;
request.quantize = [&palette](const Vec3f &rgb) {
int best = 0;
float bd = std::numeric_limits<float>::max();
for (int i = 0; i < 3; ++i)
if (const float d = (palette[size_t(i)] - rgb).squaredNorm(); d < bd) { bd = d; best = i; }
return best;
};
const auto bake = [&](const indexed_triangle_set &mesh, const Vec2f &stroke, std::vector<uint8_t> &color) {
TextureDisplacementFacetsData facets;
facets[0] = paint_top_disc(mesh, side, stroke, radius);
TextureColorRequest req = request;
req.out_triangle = &color;
return build_texture_displacement(mesh, { layer }, facets, options, {}, &req);
};
// The pipeline's own counters, over welded positions, so a mesh watertight only after welding
// still reads as closed.
const auto defects = [](const indexed_triangle_set &mesh) {
return TextureBake::count_edge_defects(TextureBake::to_soup(mesh));
};
std::vector<uint8_t> color1;
const indexed_triangle_set first = bake(cube, first_stroke, color1);
REQUIRE(first.indices.size() > cube.indices.size());
REQUIRE(color1.size() == first.indices.size());
const TextureBake::EdgeDefects d1 = defects(first);
INFO("first bake: " << d1.triangles << " triangles, " << d1.open << " open, " << d1.non_manifold << " non-manifold");
CHECK(d1.open == 0);
CHECK(d1.non_manifold == 0);
// The colour the first bake committed, as TextureDisplacementBakeJob writes it.
TriangleSelector::TriangleSplittingData painted_first;
{
const TriangleMesh mesh(first);
TriangleSelector selector(mesh);
for (size_t i = 0; i < color1.size(); ++i)
if (color1[i] != 0)
selector.set_facet(int(i), EnforcerBlockerType(color1[i]));
painted_first = selector.serialize();
}
REQUIRE_FALSE(painted_first.bitstream.empty());
std::vector<uint8_t> color2;
const indexed_triangle_set second = bake(first, second_stroke, color2);
REQUIRE(color2.size() == second.indices.size());
REQUIRE(second.indices.size() != first.indices.size()); // the topology really did change
const TextureBake::EdgeDefects d2 = defects(second);
INFO("second bake: " << d2.triangles << " triangles, " << d2.open << " open, " << d2.non_manifold << " non-manifold");
CHECK(d2.open == 0);
CHECK(d2.non_manifold == 0);
// The commit: the earlier colour carried onto the new topology, this bake's own colour over it.
std::vector<uint8_t> committed(second.indices.size(), 0);
{
const TriangleMesh mesh(second);
TriangleSelector selector(mesh);
const std::vector<uint8_t> carried = transfer_facet_paint(first, painted_first, second);
REQUIRE(carried.size() == second.indices.size());
for (size_t i = 0; i < carried.size(); ++i)
if (carried[i] != 0)
selector.set_facet(int(i), EnforcerBlockerType(carried[i]));
for (size_t i = 0; i < color2.size(); ++i)
if (color2[i] != 0)
selector.set_facet(int(i), EnforcerBlockerType(color2[i]));
for (int state = 1; state <= int(palette.size()); ++state) {
std::vector<int> src;
if (selector.get_facets_strict(EnforcerBlockerType(state), &src).indices.empty())
continue;
for (const int s : src)
if (s >= 0 && size_t(s) < committed.size() && committed[size_t(s)] == 0)
committed[size_t(s)] = uint8_t(state);
}
}
// Just the triangles the first bake coloured, to ask "how far is this from colour that was really
// laid down" of anything the commit painted.
indexed_triangle_set coloured_first;
coloured_first.vertices = first.vertices;
for (size_t i = 0; i < color1.size(); ++i)
if (color1[i] != 0)
coloured_first.indices.push_back(first.indices[i]);
const AABBTreeIndirect::Tree3f tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(
coloured_first.vertices, coloured_first.indices);
// A triangle may carry colour the second bake did not produce only where the first bake's colour
// actually was. A foot point sits on the surface the bake started from, which the relief has since
// moved, and the transfer can widen the region by a triangle, so the margin is twice the edge
// length the bake was told to refine to; landing anywhere else means the mask was read against a
// mesh it no longer matches.
size_t own = 0, carried_over = 0, stranded = 0;
float worst = 0.f;
for (size_t i = 0; i < second.indices.size(); ++i) {
if (committed[i] == 0)
continue;
if (i < color2.size() && color2[i] != 0) {
CHECK(committed[i] == color2[i]); // this bake's own colour wins where it painted
++own;
continue;
}
const stl_triangle_vertex_indices &t = second.indices[i];
const Vec3f g = (second.vertices[size_t(t[0])] + second.vertices[size_t(t[1])] +
second.vertices[size_t(t[2])]) / 3.f;
size_t hit = 0;
Vec3f foot;
const float d = std::sqrt(std::max(0.f, AABBTreeIndirect::squared_distance_to_indexed_triangle_set(
coloured_first.vertices, coloured_first.indices, tree, g, hit, foot)));
worst = std::max(worst, d);
if (d <= 2.f * refine)
++carried_over;
else
++stranded;
}
INFO("committed: " << own << " from this bake, " << carried_over << " carried over, " << stranded
<< " stranded, worst distance from the first bake's colour " << worst << " mm");
CHECK(own > 0);
CHECK(carried_over > 0); // the first bake's colour survived the second
CHECK(stranded == 0); // and nothing was painted where neither bake put colour
}
TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[TextureDisplacement]")
{
// A stroke in each of the cube's two top triangles, baked one after the other. The first bake used
@@ -2950,3 +3103,353 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu
CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{}));
}
// ---------------------------------------------------------------------------------------------
// The model's own UV channel, and the Override blend mode
// ---------------------------------------------------------------------------------------------
// A grayscale image that is black in its left half and white in its right half, so where a sample
// lands along u is readable straight off the height it comes back with.
static std::shared_ptr<std::vector<unsigned char>> make_left_right_split_png(size_t w = 16, size_t h = 16)
{
std::vector<uint8_t> pixels(w * h);
for (size_t y = 0; y < h; ++y)
for (size_t x = 0; x < w; ++x)
pixels[y * w + x] = (x < w / 2) ? 0 : 255;
const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png");
REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels));
std::vector<unsigned char> bytes;
{
std::ifstream ifs(tmp_path.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
// Where a sheet coordinate lands in uv: across [UV_LO, UV_LO + UV_SPAN] rather than the full [0, 1].
// The inset keeps the tests off the exact edge of an untiled placement, where there is deliberately no
// texture at all rather than its border texel (see DecodedHeightTexture::sample()) - a real model's
// channel does reach 1.0, and a vertex sitting exactly there displaces by nothing on purpose.
static constexpr float UV_LO = 0.1f;
static constexpr float UV_SPAN = 0.8f;
static float sheet_uv_at(float coord, float size, bool mirror = false)
{
const float t = coord / size;
return UV_LO + UV_SPAN * (mirror ? 1.f - t : t);
}
// One UV per triangle corner: u follows x, v follows y. `mirror_u` mirrors u, which is what the tests
// use to show the bake is really reading the channel rather than projecting the position.
static std::vector<Vec2f> sheet_uv_channel(const indexed_triangle_set &sheet, float size, bool mirror_u = false)
{
std::vector<Vec2f> uv(sheet.indices.size() * 3);
for (size_t f = 0; f < sheet.indices.size(); ++f)
for (int k = 0; k < 3; ++k) {
const Vec3f &p = sheet.vertices[size_t(sheet.indices[f][k])];
uv[f * 3 + size_t(k)] = Vec2f(sheet_uv_at(p.x(), size, mirror_u), sheet_uv_at(p.y(), size));
}
return uv;
}
TEST_CASE("Override blend replaces the displacement under it instead of combining with it",
"[TextureDisplacement]")
{
CHECK_THAT(blend_displacement(3.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f));
// Nothing underneath yet is still just the value, so an Override layer at the bottom of the
// stack behaves exactly like the Add the bake forces there.
CHECK_THAT(blend_displacement(0.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f));
// The sign carries, and a zero sample really does flatten what is under it - that is the point.
CHECK_THAT(blend_displacement(3.f, -1.f, TextureBlendMode::Override), WithinAbs(-1.f, 1e-6f));
CHECK_THAT(blend_displacement(3.f, 0.f, TextureBlendMode::Override), WithinAbs(0.f, 1e-6f));
}
TEST_CASE("an Override layer leaves only its own relief where two layers overlap", "[TextureDisplacement]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
TextureDisplacementLayer base;
base.slot = 0;
base.depth_mm = 2.0f;
base.tiling_scale = 5.0f;
base.image_data = make_flat_gray_png(255); // full depth everywhere
TextureDisplacementLayer second = base;
second.slot = 1;
second.depth_mm = 0.5f;
second.blend_mode = TextureBlendMode::Override;
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(cube);
facets[1] = facets[0];
const indexed_triangle_set overridden = build_texture_displacement(cube, {base, second}, facets, classic_options());
REQUIRE(overridden.vertices.size() == cube.vertices.size());
// Add would have piled the two up to 2.5 mm; Override keeps the upper layer's 0.5 mm alone.
TextureDisplacementLayer added = second;
added.blend_mode = TextureBlendMode::Add;
const indexed_triangle_set summed = build_texture_displacement(cube, {base, added}, facets, classic_options());
REQUIRE(summed.vertices.size() == cube.vertices.size());
for (size_t i = 0; i < cube.vertices.size(); ++i) {
if (!above_resting_plane(cube, i))
continue;
CHECK_THAT((overridden.vertices[i] - cube.vertices[i]).norm(), WithinAbs(0.5f, 1e-3f));
CHECK_THAT((summed.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.5f, 1e-3f));
}
}
TEST_CASE("the ModelUV mapping displaces through the model's own UV channel", "[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set sheet = make_flat_sheet(size, 1.f);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.depth_mm = 1.0f;
layer.image_data = make_left_right_split_png();
layer.tile_enabled = false; // the layout is the placement; nothing should repeat
layer.projection_method = TextureProjectionMethod::ModelUV;
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(sheet);
const std::vector<Vec2f> uv = sheet_uv_channel(sheet, size);
const std::vector<Vec2f> flipped = sheet_uv_channel(sheet, size, /* mirror_u */ true);
const indexed_triangle_set out = build_texture_displacement(sheet, {layer}, facets, classic_options(), {}, nullptr,
Transform3d::Identity(), nullptr, nullptr, &uv);
REQUIRE(out.vertices.size() == sheet.vertices.size());
// The image is black for u < 0.5 and white above it, and the channel's u rises with x across the
// sheet, so it comes out flat on its left half and raised by the full depth on its right.
size_t low = 0, high = 0;
for (size_t i = 0; i < sheet.vertices.size(); ++i) {
const float x = sheet.vertices[i].x();
const float dz = out.vertices[i].z() - sheet.vertices[i].z();
if (x < 0.4f * size) {
CHECK_THAT(dz, WithinAbs(0.f, 1e-3f));
++low;
} else if (x > 0.6f * size) {
CHECK_THAT(dz, WithinAbs(layer.depth_mm, 1e-3f));
++high;
}
}
REQUIRE(low > 0);
REQUIRE(high > 0);
// Mirroring the channel mirrors the relief: the mapping really is the channel's, not a planar
// projection of the position (which would be unchanged by it).
const indexed_triangle_set mirrored = build_texture_displacement(sheet, {layer}, facets, classic_options(), {},
nullptr, Transform3d::Identity(), nullptr, nullptr,
&flipped);
REQUIRE(mirrored.vertices.size() == sheet.vertices.size());
for (size_t i = 0; i < sheet.vertices.size(); ++i) {
const float x = sheet.vertices[i].x();
if (x < 0.4f * size)
CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(layer.depth_mm, 1e-3f));
else if (x > 0.6f * size)
CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(0.f, 1e-3f));
}
}
TEST_CASE("a ModelUV layer falls back to Triplanar when the volume carries no UV channel",
"[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set sheet = make_flat_sheet(size, 2.f);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.depth_mm = 1.0f;
layer.tiling_scale = 5.0f;
layer.image_data = make_flat_gray_png(255);
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(sheet);
layer.projection_method = TextureProjectionMethod::Triplanar;
const indexed_triangle_set as_triplanar = build_texture_displacement(sheet, {layer}, facets, classic_options());
layer.projection_method = TextureProjectionMethod::ModelUV;
const indexed_triangle_set no_channel = build_texture_displacement(sheet, {layer}, facets, classic_options());
REQUIRE(no_channel.vertices.size() == as_triplanar.vertices.size());
for (size_t i = 0; i < as_triplanar.vertices.size(); ++i)
CHECK_THAT((no_channel.vertices[i] - as_triplanar.vertices[i]).norm(), WithinAbs(0.f, 1e-5f));
}
TEST_CASE("a bake with colour off on every layer reports no colour at all", "[TextureDisplacement]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.depth_mm = 1.0f;
layer.tiling_scale = 5.0f;
layer.image_data = make_flat_rgb_png(200, 40, 10); // has colour to give...
layer.color_enabled = false; // ...but is not asked for it
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(cube);
std::vector<uint8_t> triangle_color;
TextureColorRequest request;
request.quantize = [](const Vec3f &) { return 0; }; // would colour everything, if asked
request.out_triangle = &triangle_color;
const indexed_triangle_set out = build_texture_displacement(cube, {layer}, facets, classic_options(), {}, &request);
REQUIRE_FALSE(out.vertices.empty());
// Every entry 0 ("this triangle takes no colour from the texture"), so the commit leaves whatever
// the model was already painted with exactly as it was.
for (const uint8_t entry : triangle_color)
CHECK(entry == 0);
}
TEST_CASE("transfer_uv_channel resamples a UV channel onto a subdivided copy of the same surface",
"[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set coarse = make_flat_sheet(size, 5.f);
const indexed_triangle_set fine = make_flat_sheet(size, 1.f);
const std::vector<Vec2f> uv = sheet_uv_channel(coarse, size);
const std::vector<Vec2f> moved = transfer_uv_channel(coarse, uv, fine);
REQUIRE(moved.size() == fine.indices.size() * 3);
// The channel is affine in position here (u = x/size, v = y/size), so a correct transfer
// reproduces it exactly at every corner of the finer mesh.
for (size_t f = 0; f < fine.indices.size(); ++f)
for (int k = 0; k < 3; ++k) {
const Vec3f &p = fine.vertices[size_t(fine.indices[f][k])];
CHECK_THAT(moved[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f));
CHECK_THAT(moved[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f));
}
// A mismatched channel is refused rather than read past its end.
CHECK(transfer_uv_channel(coarse, {Vec2f::Zero()}, fine).empty());
CHECK(transfer_uv_channel(coarse, uv, indexed_triangle_set{}).empty());
}
TEST_CASE("the model's per-corner UVs survive a brush split and collapse to one per vertex",
"[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set sheet = make_flat_sheet(size, 5.f);
const std::vector<Vec2f> uv = sheet_uv_channel(sheet, size);
// The whole sheet as a patch, with the source map the channel is read through.
const TriangleMesh tm(sheet);
TriangleSelector selector(tm);
for (int f = 0; f < int(sheet.indices.size()); ++f)
selector.set_facet(f, EnforcerBlockerType::ENFORCER);
std::vector<int> source;
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER, &source);
REQUIRE(patch.indices.size() == source.size());
const std::vector<Vec2f> corner = compute_model_corner_uvs(sheet, uv, patch, source);
REQUIRE(corner.size() == patch.indices.size() * 3);
// An unsplit patch triangle *is* its source triangle, so its corners reproduce the channel.
for (size_t f = 0; f < patch.indices.size(); ++f)
for (int k = 0; k < 3; ++k) {
const Vec3f &p = patch.vertices[size_t(patch.indices[f][k])];
CHECK_THAT(corner[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f));
CHECK_THAT(corner[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f));
}
const std::vector<Vec2f> per_vertex = collapse_corner_uvs(patch, corner);
REQUIRE(per_vertex.size() == patch.vertices.size());
for (size_t i = 0; i < patch.vertices.size(); ++i) {
CHECK_THAT(per_vertex[i].x(), WithinAbs(sheet_uv_at(patch.vertices[i].x(), size), 1e-4f));
CHECK_THAT(per_vertex[i].y(), WithinAbs(sheet_uv_at(patch.vertices[i].y(), size), 1e-4f));
}
// Mismatched inputs report nothing rather than guessing.
CHECK(compute_model_corner_uvs(sheet, {Vec2f::Zero()}, patch, source).empty());
CHECK(compute_model_corner_uvs(sheet, uv, patch, {}).empty());
CHECK(collapse_corner_uvs(patch, {Vec2f::Zero()}).empty());
}
TEST_CASE("facet paint is carried onto a rebuilt mesh by each facet's foot point", "[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f);
// Two filaments, split down the middle - the pattern the transfer has to reproduce.
const TriangleMesh coarse_mesh(coarse);
TriangleSelector painter(coarse_mesh);
for (size_t t = 0; t < coarse.indices.size(); ++t) {
const stl_triangle_vertex_indices &tri = coarse.indices[t];
const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() +
coarse.vertices[size_t(tri[2])].x()) / 3.f;
painter.set_facet(int(t), x < 0.5f * size ? EnforcerBlockerType::Extruder1 : EnforcerBlockerType::Extruder2);
}
const TriangleSelector::TriangleSplittingData paint = painter.serialize();
// A finer mesh, lifted clear of the original surface the way a bake's relief lifts it. This is
// what ModelVolume::restore_painting()'s 0.01 mm overlap remap cannot follow.
indexed_triangle_set lifted = make_flat_sheet(size, 1.f);
for (Vec3f &v : lifted.vertices)
v.z() += 0.5f;
const std::vector<uint8_t> state = transfer_facet_paint(coarse, paint, lifted);
REQUIRE(state.size() == lifted.indices.size());
size_t left = 0, right = 0;
for (size_t t = 0; t < lifted.indices.size(); ++t) {
const stl_triangle_vertex_indices &tri = lifted.indices[t];
const float x = (lifted.vertices[size_t(tri[0])].x() + lifted.vertices[size_t(tri[1])].x() +
lifted.vertices[size_t(tri[2])].x()) / 3.f;
// Only away from the split: a facet straddling it may legitimately land on either side.
if (x < 0.4f * size) {
CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder1));
++left;
} else if (x > 0.6f * size) {
CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder2));
++right;
}
}
REQUIRE(left > 0);
REQUIRE(right > 0);
// An unpainted source has nothing to carry, which is not the same as carrying NONE everywhere:
// the caller has to tell those apart to decide between resetting the annotation and writing one.
CHECK(transfer_facet_paint(coarse, TriangleSelector::TriangleSplittingData{}, lifted).empty());
CHECK(transfer_facet_paint(coarse, paint, indexed_triangle_set{}).empty());
}
TEST_CASE("an unpainted part of the source carries no paint onto the rebuilt mesh", "[TextureDisplacement]")
{
constexpr float size = 10.f;
const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f);
// Only the left half is painted at all; the right half must come back NONE rather than borrowing
// the nearest painted colour from across the model.
const TriangleMesh coarse_mesh(coarse);
TriangleSelector painter(coarse_mesh);
for (size_t t = 0; t < coarse.indices.size(); ++t) {
const stl_triangle_vertex_indices &tri = coarse.indices[t];
const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() +
coarse.vertices[size_t(tri[2])].x()) / 3.f;
if (x < 0.5f * size)
painter.set_facet(int(t), EnforcerBlockerType::Extruder3);
}
const std::vector<uint8_t> state = transfer_facet_paint(coarse, painter.serialize(), coarse);
REQUIRE(state.size() == coarse.indices.size());
for (size_t t = 0; t < coarse.indices.size(); ++t) {
const stl_triangle_vertex_indices &tri = coarse.indices[t];
const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() +
coarse.vertices[size_t(tri[2])].x()) / 3.f;
if (x < 0.4f * size)
CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder3));
else if (x > 0.6f * size)
CHECK(state[t] == uint8_t(EnforcerBlockerType::NONE));
}
}
@@ -133,28 +133,6 @@ TEST_CASE("Type1 sizes the tower from each filament's own prime volume", "[WipeT
CHECK_THAT(rib.depth, WithinAbs(WipeTower::estimate_rib_tower_bbox_side({{30.f, 0}, {45.f, 0}}, 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 5.f), 1e-4));
}
TEST_CASE("A mixed slot is purged as its physical components", "[WipeTowerEstimate]") {
// Slots 4 and 5 blend 1+2 and 1+3: every layer prints one component per mixed slot, so a
// plate using all five slots purges the three physical filaments and nothing more.
const WipeTowerType type = GENERATE(WipeTowerType::Type1, WipeTowerType::Type2);
DynamicPrintConfig config = make_config();
config.set_key_value("filament_prime_volume", new ConfigOptionFloats({100., 100., 100., 100., 100.}));
config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({0, 0, 0, 0, 0}));
config.set_key_value("filament_is_mixed", new ConfigOptionBools(std::vector<unsigned char>{0, 0, 0, 1, 1}));
config.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", "", "1,2", "1,3"}));
const double physical = estimate(config, 3, 0.2, 5., type).depth;
REQUIRE(physical > estimate(config, 2, 0.2, 5., type).depth);
CHECK_THAT(estimate(config, 5, 0.2, 5., type).depth, WithinAbs(physical, 1e-9));
// The same slots with the mixed flags cleared are five filaments, which is what sized the
// tower before and refused positions the printed tower fits.
DynamicPrintConfig unmixed = config;
unmixed.set_key_value("filament_is_mixed", new ConfigOptionBools(std::vector<unsigned char>{0, 0, 0, 0, 0}));
CHECK(estimate(unmixed, 5, 0.2, 5., type).depth > physical);
// Mixed slots alone still purge their components, and a single one already changes tools.
CHECK_THAT(estimate_wipe_tower_footprint(config, type, {3, 4}, 0.2, 5.).depth, WithinAbs(physical, 1e-9));
CHECK_THAT(estimate_wipe_tower_footprint(config, type, {3}, 0.2, 5.).depth, WithinAbs(estimate(config, 2, 0.2, 5., type).depth, 1e-9));
}
TEST_CASE("A second nozzle adds the ramming of one nozzle change per layer", "[WipeTowerEstimate]") {
// Two filaments on two nozzles: the tool order crosses once per layer, and Type1 rams 10 mm
// of filament as three 1.0 mm nozzle-change lines (see the WipeTower case).