diff --git a/.github/actions/deploy-nightly/action.yml b/.github/actions/deploy-nightly/action.yml
new file mode 100644
index 0000000000..cfcd1c7fcb
--- /dev/null
+++ b/.github/actions/deploy-nightly/action.yml
@@ -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"
diff --git a/.github/actions/deploy-nightly/deploy.sh b/.github/actions/deploy-nightly/deploy.sh
new file mode 100644
index 0000000000..9d5b22b9a8
--- /dev/null
+++ b/.github/actions/deploy-nightly/deploy.sh
@@ -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
diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml
index 22790fb1e8..8fa7901623 100644
--- a/.github/workflows/build_all.yml
+++ b/.github/workflows/build_all.yml
@@ -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
diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml
index e49e157358..4d85542c71 100644
--- a/.github/workflows/build_orca.yml
+++ b/.github/workflows/build_orca.yml
@@ -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.
diff --git a/resources/images/texture_displacement_map_model_uv.svg b/resources/images/texture_displacement_map_model_uv.svg
new file mode 100644
index 0000000000..c303d4e0ac
--- /dev/null
+++ b/resources/images/texture_displacement_map_model_uv.svg
@@ -0,0 +1 @@
+
diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs
index a1e1ac8069..7508048509 100644
--- a/resources/shaders/110/texture_displacement_shaded.fs
+++ b/resources/shaders/110/texture_displacement_shaded.fs
@@ -77,17 +77,21 @@ varying float weight;
varying float island_active;
varying vec2 vertex_uv;
-// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including
-// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so
-// the preview wraps the texture the same way round as the bake.
+// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right
+// handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the
+// cylinder is to the viewer's right rather than mirrored.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary));
- fwd = normalize(cross(right, up));
+ fwd = normalize(cross(up, right));
}
+// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
+// sign(), which returns 0 there and would collapse the coordinate onto a line.
+float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
+
// The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's
// tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(),
// project_cylindrical() and project_spherical(). `n` is read by the planar mode only.
@@ -99,7 +103,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center;
float x = dot(rel, right);
float y = dot(rel, fwd);
- return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up));
+ return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
}
if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius
vec3 rel = p - patch_center;
@@ -107,10 +111,18 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8)
return vec2(0.0);
vec3 dir = rel / radius;
- return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius;
+ return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
}
- vec3 an = abs(n); // Triplanar: drop the dominant normal axis
- return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy);
+ // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0)
+ // sits at the top of the model instead of upside down, and u is flipped on the far side of each
+ // plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two
+ // siblings, term for term.
+ vec3 an = abs(n);
+ if (an.x >= an.y && an.x >= an.z)
+ return vec2(box_face_sign(n.x) * p.y, -p.z);
+ if (an.y >= an.x && an.y >= an.z)
+ return vec2(-box_face_sign(n.y) * p.x, -p.z);
+ return vec2(box_face_sign(n.z) * p.x, -p.y);
}
// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one
@@ -129,7 +141,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec2 xy = vec2(dot(rel, right), dot(rel, fwd));
float r = length(xy);
t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it
- b = up; // v is the distance along the axis
+ b = -up; // v is the distance *against* the axis
return;
}
if (projection_mode == 2) {
@@ -138,20 +150,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0);
float c = length(dir.xy); // cos(latitude)
t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0);
- b = cross(dir, t); // increasing latitude, unit length
+ b = -cross(dir, t); // v grows away from the pole, unit length
units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0);
return;
}
- vec3 an = abs(n);
- if (an.x >= an.y && an.x >= an.z) { // planar = p.yz
- t = vec3(0.0, 1.0, 0.0);
- b = vec3(0.0, 0.0, 1.0);
- } else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz
- t = vec3(1.0, 0.0, 0.0);
- b = vec3(0.0, 0.0, 1.0);
- } else { // planar = p.xy
- t = vec3(1.0, 0.0, 0.0);
- b = vec3(0.0, 1.0, 0.0);
+ vec3 an = abs(n); // the same signed basis projection_raw() uses
+ if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z)
+ t = vec3(0.0, box_face_sign(n.x), 0.0);
+ b = vec3(0.0, 0.0, -1.0);
+ } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z)
+ t = vec3(-box_face_sign(n.y), 0.0, 0.0);
+ b = vec3(0.0, 0.0, -1.0);
+ } else { // planar = (+-x, -y)
+ t = vec3(box_face_sign(n.z), 0.0, 0.0);
+ b = vec3(0.0, -1.0, 0.0);
}
}
diff --git a/resources/shaders/110/texture_displacement_uvcheck.fs b/resources/shaders/110/texture_displacement_uvcheck.fs
index f9e998adc2..37fd1826f2 100644
--- a/resources/shaders/110/texture_displacement_uvcheck.fs
+++ b/resources/shaders/110/texture_displacement_uvcheck.fs
@@ -36,14 +36,20 @@ varying vec4 world_pos;
varying float distortion;
varying vec2 vertex_uv;
+// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows
+// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary));
- fwd = normalize(cross(right, up));
+ fwd = normalize(cross(up, right));
}
+// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
+// sign(), which returns 0 there and would collapse the coordinate onto a line.
+float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
+
// Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm.
vec2 projection_raw(vec3 p, vec3 n)
{
@@ -53,7 +59,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center;
float x = dot(rel, right);
float y = dot(rel, fwd);
- return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up));
+ return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
}
if (projection_mode == 2) {
vec3 rel = p - patch_center;
@@ -61,10 +67,16 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8)
return vec2(0.0);
vec3 dir = rel / radius;
- return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius;
+ return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
}
+ // v negated so the image's first pixel row sits at the top of the model, u flipped on the far side
+ // of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz().
vec3 an = abs(n);
- return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy);
+ if (an.x >= an.y && an.x >= an.z)
+ return vec2(box_face_sign(n.x) * p.y, -p.z);
+ if (an.y >= an.x && an.y >= an.z)
+ return vec2(-box_face_sign(n.y) * p.x, -p.z);
+ return vec2(box_face_sign(n.z) * p.x, -p.y);
}
vec2 project_uv(vec3 p, vec3 n)
diff --git a/resources/shaders/140/texture_displacement_shaded.fs b/resources/shaders/140/texture_displacement_shaded.fs
index e90de36ad1..77de288be5 100644
--- a/resources/shaders/140/texture_displacement_shaded.fs
+++ b/resources/shaders/140/texture_displacement_shaded.fs
@@ -143,17 +143,21 @@ in vec2 vertex_uv;
out vec4 out_color;
-// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including
-// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so
-// the preview wraps the texture the same way round as the bake.
+// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right
+// handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the
+// cylinder is to the viewer's right rather than mirrored.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary));
- fwd = normalize(cross(right, up));
+ fwd = normalize(cross(up, right));
}
+// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
+// sign(), which returns 0 there and would collapse the coordinate onto a line.
+float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
+
// The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's
// tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(),
// project_cylindrical() and project_spherical(). `n` is read by the planar mode only.
@@ -165,7 +169,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center;
float x = dot(rel, right);
float y = dot(rel, fwd);
- return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up));
+ return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
}
if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius
vec3 rel = p - patch_center;
@@ -173,10 +177,18 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8)
return vec2(0.0);
vec3 dir = rel / radius;
- return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius;
+ return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
}
- vec3 an = abs(n); // Triplanar: drop the dominant normal axis
- return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy);
+ // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0)
+ // sits at the top of the model instead of upside down, and u is flipped on the far side of each
+ // plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two
+ // siblings, term for term.
+ vec3 an = abs(n);
+ if (an.x >= an.y && an.x >= an.z)
+ return vec2(box_face_sign(n.x) * p.y, -p.z);
+ if (an.y >= an.x && an.y >= an.z)
+ return vec2(-box_face_sign(n.y) * p.x, -p.z);
+ return vec2(box_face_sign(n.z) * p.x, -p.y);
}
// The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one
@@ -195,7 +207,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec2 xy = vec2(dot(rel, right), dot(rel, fwd));
float r = length(xy);
t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it
- b = up; // v is the distance along the axis
+ b = -up; // v is the distance *against* the axis
return;
}
if (projection_mode == 2) {
@@ -204,20 +216,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0);
float c = length(dir.xy); // cos(latitude)
t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0);
- b = cross(dir, t); // increasing latitude, unit length
+ b = -cross(dir, t); // v grows away from the pole, unit length
units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0);
return;
}
- vec3 an = abs(n);
- if (an.x >= an.y && an.x >= an.z) { // planar = p.yz
- t = vec3(0.0, 1.0, 0.0);
- b = vec3(0.0, 0.0, 1.0);
- } else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz
- t = vec3(1.0, 0.0, 0.0);
- b = vec3(0.0, 0.0, 1.0);
- } else { // planar = p.xy
- t = vec3(1.0, 0.0, 0.0);
- b = vec3(0.0, 1.0, 0.0);
+ vec3 an = abs(n); // the same signed basis projection_raw() uses
+ if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z)
+ t = vec3(0.0, box_face_sign(n.x), 0.0);
+ b = vec3(0.0, 0.0, -1.0);
+ } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z)
+ t = vec3(-box_face_sign(n.y), 0.0, 0.0);
+ b = vec3(0.0, 0.0, -1.0);
+ } else { // planar = (+-x, -y)
+ t = vec3(box_face_sign(n.z), 0.0, 0.0);
+ b = vec3(0.0, -1.0, 0.0);
}
}
diff --git a/resources/shaders/140/texture_displacement_uvcheck.fs b/resources/shaders/140/texture_displacement_uvcheck.fs
index e6fc8036da..34f80df2bd 100644
--- a/resources/shaders/140/texture_displacement_uvcheck.fs
+++ b/resources/shaders/140/texture_displacement_uvcheck.fs
@@ -47,14 +47,20 @@ in vec2 vertex_uv;
out vec4 out_color;
+// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows
+// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder.
void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd)
{
up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0);
vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
right = normalize(cross(up, arbitrary));
- fwd = normalize(cross(right, up));
+ fwd = normalize(cross(up, right));
}
+// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own
+// sign(), which returns 0 there and would collapse the coordinate onto a line.
+float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; }
+
// Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm.
vec2 projection_raw(vec3 p, vec3 n)
{
@@ -64,7 +70,7 @@ vec2 projection_raw(vec3 p, vec3 n)
vec3 rel = p - patch_center;
float x = dot(rel, right);
float y = dot(rel, fwd);
- return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up));
+ return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up));
}
if (projection_mode == 2) {
vec3 rel = p - patch_center;
@@ -72,10 +78,16 @@ vec2 projection_raw(vec3 p, vec3 n)
if (radius < 1e-8)
return vec2(0.0);
vec3 dir = rel / radius;
- return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius;
+ return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius;
}
+ // v negated so the image's first pixel row sits at the top of the model, u flipped on the far side
+ // of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz().
vec3 an = abs(n);
- return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy);
+ if (an.x >= an.y && an.x >= an.z)
+ return vec2(box_face_sign(n.x) * p.y, -p.z);
+ if (an.y >= an.x && an.y >= an.z)
+ return vec2(-box_face_sign(n.y) * p.x, -p.z);
+ return vec2(box_face_sign(n.z) * p.x, -p.y);
}
vec2 project_uv(vec3 p, vec3 n)
diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp
index f41a4443fb..9d166805a5 100644
--- a/src/libslic3r/GCode/GCodeProcessor.cpp
+++ b/src/libslic3r/GCode/GCodeProcessor.cpp
@@ -2602,12 +2602,13 @@ void GCodeProcessorResult::reset() {
//BBS: add mutex for protection of gcode result
lock();
- moves.clear();
+ // moves and lines_ends grow with the print: free them, as no reset result reuses the capacity.
+ clear_and_shrink(moves);
plate_mass = {};
object_masses.clear();
body_masses.clear();
support_masses.clear();
- lines_ends.clear();
+ clear_and_shrink(lines_ends);
printable_area = Pointfs();
//BBS: add bed exclude area
bed_exclude_area = Pointfs();
diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp
index d096bd2158..2f065f09de 100644
--- a/src/libslic3r/Model.cpp
+++ b/src/libslic3r/Model.cpp
@@ -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 ModelVolume::save_painting() const
diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp
index dcaea5ee97..d7859446b5 100644
--- a/src/libslic3r/Model.hpp
+++ b/src/libslic3r/Model.hpp
@@ -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 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);
diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp
index c7ba6b83ea..871afcc65c 100644
--- a/src/libslic3r/TextureDisplacement.cpp
+++ b/src/libslic3r/TextureDisplacement.cpp
@@ -172,12 +172,33 @@ struct SmoothedTextureCache
{
std::weak_ptr> source;
float smoothing = 0.f;
+ // Part of the key, not of the result: smoothing is applied *after* the panel's adjustments, so a
+ // blur computed over one set of adjustments is not the blur another set would give.
+ TextureAdjustments adjust;
DecodedHeightTexture texture;
};
std::mutex mutex;
std::unordered_map entries;
};
SmoothedTextureCache g_smoothed_texture_cache;
+
+// The adjusted copy, cached between the raw decode and the smoothed one. Needed for the same reason the
+// smoothed cache is: while a slider in the image-adjustment panel moves, the thumbnail, the shaded
+// preview's height and colour textures, the UV editor backdrop and the displacement preview job all ask
+// for the same (image, adjustments) pair within one frame, and each of them running a full-size resample
+// of its own is what would make the panel unusable on a large texture.
+struct AdjustedTextureCache
+{
+ struct Entry
+ {
+ std::weak_ptr> source;
+ TextureAdjustments adjust;
+ DecodedHeightTexture texture;
+ };
+ std::mutex mutex;
+ std::unordered_map entries;
+};
+AdjustedTextureCache g_adjusted_texture_cache;
} // namespace
namespace {
@@ -625,6 +646,34 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
entries[key] = { std::weak_ptr>(layer.image_data), result };
}
+ // The image-adjustment panel's live edits, on top of the decode and before the smoothing. Before,
+ // because `preview_adjust` is an edit to the image while `smoothing` is a live low-pass over whatever
+ // the image is - the same order the panel presents them in, and the same order a commit bakes in.
+ //
+ // Nothing applies here once the panel has closed: it resets preview_adjust to identity, and identity
+ // is a no-op that costs one comparison.
+ if (!layer.preview_adjust.identity()) {
+ bool have_adjusted = false;
+ {
+ std::lock_guard lock(g_adjusted_texture_cache.mutex);
+ auto it = g_adjusted_texture_cache.entries.find(key);
+ if (it != g_adjusted_texture_cache.entries.end() && it->second.adjust == layer.preview_adjust &&
+ it->second.source.lock() == layer.image_data) {
+ result = it->second.texture;
+ have_adjusted = true;
+ }
+ }
+ if (!have_adjusted) {
+ result = apply_texture_adjustments(result, layer.preview_adjust);
+ std::lock_guard lock(g_adjusted_texture_cache.mutex);
+ auto &entries = g_adjusted_texture_cache.entries;
+ for (auto it = entries.begin(); it != entries.end();)
+ it = it->second.source.expired() ? entries.erase(it) : std::next(it);
+ entries[key] = { std::weak_ptr>(layer.image_data),
+ layer.preview_adjust, result };
+ }
+ }
+
// Smoothing radius scales with the texture so the same slider value blurs the same *fraction* of
// the image whatever resolution it came in at, and stays continuous in the slider - see
// smooth_height_pixels(). The cap is a cost limit, not part of the mapping: the blur is
@@ -635,7 +684,7 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
std::lock_guard lock(g_smoothed_texture_cache.mutex);
auto it = g_smoothed_texture_cache.entries.find(key);
if (it != g_smoothed_texture_cache.entries.end() && it->second.smoothing == layer.smoothing &&
- it->second.source.lock() == layer.image_data)
+ it->second.adjust == layer.preview_adjust && it->second.source.lock() == layer.image_data)
return it->second.texture;
}
const float span = 0.05f * float(std::min(result.width, result.height));
@@ -659,12 +708,70 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
auto &entries = g_smoothed_texture_cache.entries;
for (auto it = entries.begin(); it != entries.end();)
it = it->second.source.expired() ? entries.erase(it) : std::next(it);
- entries[key] = { std::weak_ptr>(layer.image_data), layer.smoothing, result };
+ entries[key] = { std::weak_ptr>(layer.image_data), layer.smoothing,
+ layer.preview_adjust, result };
}
return result;
}
namespace {
+// Fraction of the short side that TextureAdjustments::blur == 1 blurs over, and the cost cap on it.
+// Deliberately the same pair decode_height_texture() uses for the Smoothing slider - see
+// adjustment_blur_radius().
+constexpr float SOURCE_BLUR_SPAN = 0.05f;
+constexpr float SOURCE_BLUR_MAX_PX = 48.f;
+
+// Radius of the unsharp mask's blurred reference, in texels. Small and only mildly resolution-dependent:
+// a sharpen is meant to act on the finest detail the image holds, so its radius tracks the texel grid
+// rather than the picture's size. 1024x1024 gives ~2 texels, the usual photo-editor default.
+float unsharp_radius(int width, int height)
+{
+ return std::max(1.f, 0.002f * float(std::min(width, height)));
+}
+
+// Brightness as a *lift* toward white (or toward black) rather than an additive offset: positive b maps
+// v to v + b*(1 - v), negative to v*(1 + b). Both reach the endpoint exactly at |b| == 1 while staying
+// monotonic and non-clipping in between, which an additive shift does not - it drives a whole region to
+// a flat 255 and, because this image is also the height map, that region bakes as flat relief rather
+// than as bright relief. Keeping the curve injective keeps the detail in it.
+float apply_brightness(float v, float b)
+{
+ return b >= 0.f ? v + b * (1.f - v) : v * (1.f + b);
+}
+
+// Contrast about the 0.5 pivot. Gain is 1/(1-c) going up (c == 1 would be an infinite-slope threshold, so
+// the caller's clamp keeps it finite) and 1+c going down, reaching a flat mid-grey at c == -1.
+float apply_contrast(float v, float c)
+{
+ const float gain = c >= 0.f ? 1.f / std::max(1e-3f, 1.f - c) : 1.f + c;
+ return (v - 0.5f) * gain + 0.5f;
+}
+
+// Blur then sharpen one 8-bit channel in place. Shared by the grayscale path and by each of a colour
+// image's three channels, so every channel gets the identical spatial treatment and a colour image's
+// hue cannot shift just because its channels were filtered differently.
+void adjust_channel_spatial(std::vector &channel, int width, int height, float blur, float sharpness)
+{
+ if (blur > 0.f)
+ smooth_height_pixels(channel, width, height, adjustment_blur_radius(blur, width, height));
+
+ if (sharpness <= 0.f)
+ return;
+
+ // Unsharp mask: push each texel away from its local average by `amount` times the difference. The
+ // blurred reference is a copy, so the sharpen reads the un-sharpened neighbourhood everywhere rather
+ // than its own partly-written output.
+ std::vector base = channel;
+ smooth_height_pixels(base, width, height, unsharp_radius(width, height));
+ const float amount = 1.5f * std::clamp(sharpness, 0.f, 1.f);
+ tbb::parallel_for(tbb::blocked_range(0, channel.size()), [&](const tbb::blocked_range &r) {
+ for (size_t i = r.begin(); i < r.end(); ++i) {
+ const float v = float(channel[i]) + amount * (float(channel[i]) - float(base[i]));
+ channel[i] = uint8_t(std::lround(std::clamp(v, 0.f, 255.f)));
+ }
+ });
+}
+
struct TextureDetailCache
{
std::mutex mutex;
@@ -673,6 +780,150 @@ struct TextureDetailCache
TextureDetailCache g_texture_detail_cache;
} // namespace
+float adjustment_blur_radius(float blur, int width, int height)
+{
+ if (blur <= 0.f || width <= 0 || height <= 0)
+ return 0.f;
+ const float span = SOURCE_BLUR_SPAN * float(std::min(width, height));
+ return std::clamp(blur, 0.f, 1.f) * std::min(span, SOURCE_BLUR_MAX_PX);
+}
+
+DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px)
+{
+ if (src.empty() || max_px <= 0 || (src.width <= max_px && src.height <= max_px))
+ return src;
+
+ const int scale = std::max(1, (std::max(src.width, src.height) + max_px - 1) / max_px);
+ const int w = std::max(1, src.width / scale);
+ const int h = std::max(1, src.height / scale);
+ const bool color = src.has_color();
+
+ DecodedHeightTexture out;
+ out.width = w;
+ out.height = h;
+ out.pixels.resize(size_t(w) * size_t(h));
+ if (color)
+ out.rgb.resize(size_t(w) * size_t(h) * 3);
+
+ tbb::parallel_for(tbb::blocked_range(0, h), [&](const tbb::blocked_range &r) {
+ for (int y = r.begin(); y < r.end(); ++y)
+ for (int x = 0; x < w; ++x) {
+ // The source block this destination texel covers. The max() keeps the block non-empty
+ // when rounding would otherwise collapse it.
+ const int x0 = x * src.width / w, x1 = std::max(x0 + 1, (x + 1) * src.width / w);
+ const int y0 = y * src.height / h, y1 = std::max(y0 + 1, (y + 1) * src.height / h);
+ uint32_t sum_h = 0, sum_c[3] = { 0, 0, 0 }, n = 0;
+ for (int sy = y0; sy < y1 && sy < src.height; ++sy)
+ for (int sx = x0; sx < x1 && sx < src.width; ++sx, ++n) {
+ const size_t si = size_t(sy) * size_t(src.width) + size_t(sx);
+ sum_h += src.pixels[si];
+ if (color)
+ for (int c = 0; c < 3; ++c)
+ sum_c[c] += src.rgb[si * 3 + size_t(c)];
+ }
+ const size_t di = size_t(y) * size_t(w) + size_t(x);
+ out.pixels[di] = n > 0 ? uint8_t(sum_h / n) : 0;
+ if (color)
+ for (int c = 0; c < 3; ++c)
+ out.rgb[di * 3 + size_t(c)] = n > 0 ? uint8_t(sum_c[c] / n) : 0;
+ }
+ });
+ return out;
+}
+
+DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj)
+{
+ DecodedHeightTexture out = src;
+ if (src.empty() || adj.identity())
+ return out;
+
+ const int w = out.width;
+ const int h = out.height;
+ const size_t n = size_t(w) * size_t(h);
+
+ const float brightness = std::clamp(adj.brightness, -1.f, 1.f);
+ // Short of 1, so apply_contrast()'s gain stays finite; at this value it is already a 1000x slope,
+ // i.e. a threshold in all but name.
+ const float contrast = std::clamp(adj.contrast, -1.f, 0.999f);
+ const float blur = std::clamp(adj.blur, 0.f, 1.f);
+ const float sharpness = std::clamp(adj.sharpness, 0.f, 1.f);
+
+ // The tonal pass: a 256-entry lookup table, because brightness and contrast are the same scalar
+ // curve for every texel and every channel. Building it once beats evaluating two curves a few
+ // million times, and it is what keeps this pass off the panel's critical path.
+ std::array tone{};
+ for (int i = 0; i < 256; ++i) {
+ float v = float(i) / 255.f;
+ v = apply_brightness(v, brightness);
+ v = apply_contrast(v, contrast);
+ tone[size_t(i)] = uint8_t(std::lround(std::clamp(v, 0.f, 1.f) * 255.f));
+ }
+
+ if (!out.has_color()) {
+ // Grayscale: one channel, and hue has nothing to turn.
+ tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) {
+ for (size_t i = r.begin(); i < r.end(); ++i)
+ out.pixels[i] = tone[out.pixels[i]];
+ });
+ adjust_channel_spatial(out.pixels, w, h, blur, sharpness);
+ return out;
+ }
+
+ // Colour: tone each channel through the same table, then rotate the hue about the luminance axis.
+ //
+ // The rotation is feColorMatrix's hueRotate matrix (SVG filters), which holds Rec.709 luma fixed.
+ // The height below is taken with decode_height_texture()'s Rec.601 coefficients instead, so a hue
+ // turn is not perfectly height-neutral - a few levels at full saturation. Reconciling the two would
+ // mean a different rotation than every other tool's, which is the worse trade: the user is watching
+ // the colour, and the height follows the colour by construction either way.
+ const float theta = adj.hue_deg * float(M_PI) / 180.f;
+ const float c = std::cos(theta);
+ const float s = std::sin(theta);
+ const float m[9] = {
+ 0.213f + c * 0.787f - s * 0.213f, 0.715f - c * 0.715f - s * 0.715f, 0.072f - c * 0.072f + s * 0.928f,
+ 0.213f - c * 0.213f + s * 0.143f, 0.715f + c * 0.285f + s * 0.140f, 0.072f - c * 0.072f - s * 0.283f,
+ 0.213f - c * 0.213f - s * 0.787f, 0.715f - c * 0.715f + s * 0.715f, 0.072f + c * 0.928f + s * 0.072f,
+ };
+ const bool turn_hue = adj.hue_deg != 0.f;
+
+ tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) {
+ for (size_t i = r.begin(); i < r.end(); ++i) {
+ const float rr = float(tone[out.rgb[i * 3]]);
+ const float gg = float(tone[out.rgb[i * 3 + 1]]);
+ const float bb = float(tone[out.rgb[i * 3 + 2]]);
+ const float nr = turn_hue ? m[0] * rr + m[1] * gg + m[2] * bb : rr;
+ const float ng = turn_hue ? m[3] * rr + m[4] * gg + m[5] * bb : gg;
+ const float nb = turn_hue ? m[6] * rr + m[7] * gg + m[8] * bb : bb;
+ out.rgb[i * 3] = uint8_t(std::lround(std::clamp(nr, 0.f, 255.f)));
+ out.rgb[i * 3 + 1] = uint8_t(std::lround(std::clamp(ng, 0.f, 255.f)));
+ out.rgb[i * 3 + 2] = uint8_t(std::lround(std::clamp(nb, 0.f, 255.f)));
+ }
+ });
+
+ // The spatial pass wants one channel at a time (smooth_height_pixels() is a single-channel blur, and
+ // reusing it is what keeps this consistent with Smoothing), so de-interleave, filter, re-interleave.
+ if (blur > 0.f || sharpness > 0.f) {
+ std::vector channel(n);
+ for (int ch = 0; ch < 3; ++ch) {
+ for (size_t i = 0; i < n; ++i)
+ channel[i] = out.rgb[i * 3 + size_t(ch)];
+ adjust_channel_spatial(channel, w, h, blur, sharpness);
+ for (size_t i = 0; i < n; ++i)
+ out.rgb[i * 3 + size_t(ch)] = channel[i];
+ }
+ }
+
+ // Height is the luminance of the colour that came out, not the toned luminance of the colour that
+ // went in: the two differ once hue rotation or a per-channel clamp has moved the channels around,
+ // and a height that disagrees with the colour it is stored next to is the bug this avoids.
+ tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) {
+ for (size_t i = r.begin(); i < r.end(); ++i)
+ out.pixels[i] = uint8_t(std::lround(0.299 * out.rgb[i * 3] + 0.587 * out.rgb[i * 3 + 1] +
+ 0.114 * out.rgb[i * 3 + 2]));
+ });
+ return out;
+}
+
TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer)
{
TextureDetail out;
@@ -778,6 +1029,57 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set
return out;
}
+namespace {
+// Which side of a world plane a face is on, with an exactly zero component counted as the positive
+// side. The tie never shows: a normal component of zero carries zero tri-planar blend weight (see
+// triplanar_weights()), so the plane it orients contributes nothing at that point.
+inline float box_face_sign(float n) { return n >= 0.f ? 1.f : -1.f; }
+
+// The box projection's 2D basis for one of the three world planes, oriented the way a viewer
+// standing outside the model sees that face.
+//
+// v runs *down* the image, because that is what v means to the sampler: v = 0 is the texture's first
+// pixel row, i.e. its top (DecodedHeightTexture::sample(); the UV editor negates Y on screen for the
+// same reason, see UVEditorCanvas::render()). So v = -z on the four upright planes and v = -y on the
+// horizontal one, which is what puts the top of an image at the top of the model rather than
+// upside down.
+//
+// u then has to depend on which *side* of the plane the face is on, or one of the two faces sharing
+// a plane comes out left-right mirrored: with a single u per plane, a portrait image reads upright on
+// +X and 180 degrees round on -X. Fixing that is the sign below, chosen so that u x v == -normal,
+// i.e. (right, down, into the surface) is right-handed for a viewer outside:
+//
+// face u v face u v face u v
+// +X +y -z +Y -x -z +Z +x -y
+// -X -y -z -Y +x -z -Z -x -y
+//
+// The sign flips where its own normal component crosses zero, and the blend weight of the plane it
+// belongs to is exactly zero there, so the blended tri-planar sum stays continuous across the flip -
+// it is not a return of the hard axis switch described on TextureProjectionMethod::Triplanar.
+inline Vec2f box_plane_yz(const Vec3f &p, float nx) { return Vec2f(box_face_sign(nx) * p.y(), -p.z()); }
+inline Vec2f box_plane_xz(const Vec3f &p, float ny) { return Vec2f(-box_face_sign(ny) * p.x(), -p.z()); }
+inline Vec2f box_plane_xy(const Vec3f &p, float nz) { return Vec2f(box_face_sign(nz) * p.x(), -p.y()); }
+} // namespace
+
+void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis)
+{
+ // The same table as box_plane_yz()/box_plane_xz()/box_plane_xy(), handed out as a pair of world
+ // directions for the plane the normal is closest to. Two shapes of one table, because the bake's
+ // hot path wants the closed form and the GUI wants the axes; the test suite checks them against
+ // each other so they cannot drift apart.
+ const Vec3f n = normal.cwiseAbs();
+ if (n.x() >= n.y() && n.x() >= n.z()) {
+ u_axis = box_face_sign(normal.x()) * Vec3f::UnitY();
+ v_axis = -Vec3f::UnitZ();
+ } else if (n.y() >= n.x() && n.y() >= n.z()) {
+ u_axis = -box_face_sign(normal.y()) * Vec3f::UnitX();
+ v_axis = -Vec3f::UnitZ();
+ } else {
+ u_axis = box_face_sign(normal.z()) * Vec3f::UnitX();
+ v_axis = -Vec3f::UnitY();
+ }
+}
+
Vec2f project_planar(const Vec3f &position, const Vec3f &normal)
{
// Planar-project onto the two axes orthogonal to the dominant component of `normal`. Called
@@ -788,23 +1090,26 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal)
// other face in the same patch - exactly the bug an earlier version of this feature had).
const Vec3f n = normal.cwiseAbs();
if (n.x() >= n.y() && n.x() >= n.z())
- return Vec2f(position.y(), position.z());
+ return box_plane_yz(position, normal.x());
if (n.y() >= n.x() && n.y() >= n.z())
- return Vec2f(position.x(), position.z());
- return Vec2f(position.x(), position.y());
+ return box_plane_xz(position, normal.y());
+ return box_plane_xy(position, normal.z());
}
namespace {
// Wrapped around patch_axis, centered at patch_center. u is the arc length (mm) around the axis at
-// this point's own radius, v is the signed distance along the axis - a reasonable approximation
-// for roughly cylindrical selections, not an exact fit for arbitrary geometry.
+// this point's own radius, v is the distance along the axis, measured *against* it - a reasonable
+// approximation for roughly cylindrical selections, not an exact fit for arbitrary geometry.
Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, const Vec3f &patch_axis)
{
Vec3f up = patch_axis;
up = (up.norm() > 1e-8f) ? Vec3f(up.normalized()) : Vec3f::UnitZ();
const Vec3f arbitrary = (std::abs(up.dot(Vec3f::UnitZ())) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX();
const Vec3f right = Vec3f(up.cross(arbitrary).normalized());
- const Vec3f fwd = Vec3f(right.cross(up).normalized());
+ // up x right, not right x up: that makes (right, fwd, up) right-handed, so the angle below grows
+ // anticlockwise about the axis - which, seen from outside the cylinder, is to the viewer's right.
+ // The other way round the texture wraps on mirrored, as if seen through the model from behind.
+ const Vec3f fwd = Vec3f(up.cross(right).normalized());
const Vec3f rel = position - patch_center;
const float along_axis = rel.dot(up);
@@ -813,7 +1118,9 @@ Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, cons
const float radius = std::sqrt(x * x + y * y);
const float angle = std::atan2(y, x);
- return Vec2f(angle * radius, along_axis);
+ // Negated, like every other projection's v: v = 0 is the texture's top row, so v has to grow
+ // *down* the axis for the top of the image to sit at the top of the model.
+ return Vec2f(angle * radius, -along_axis);
}
// Longitude/latitude around patch_center. u/v are scaled by this point's own distance from the
@@ -829,7 +1136,9 @@ Vec2f project_spherical(const Vec3f &position, const Vec3f &patch_center)
const Vec3f dir = rel / radius;
const float longitude = std::atan2(dir.y(), dir.x());
const float latitude = std::asin(std::clamp(dir.z(), -1.f, 1.f));
- return Vec2f(longitude, latitude) * radius;
+ // Longitude already grows to the right as seen from outside; latitude grows toward the north
+ // pole, so it is negated to put the image's top row (v = 0) at the top of the model.
+ return Vec2f(longitude, -latitude) * radius;
}
// CGAL's LSCM parameterizer expects a clean mesh with no isolated (unreferenced) vertices - but
@@ -1124,9 +1433,26 @@ std::vector segment_into_charts(const indexed_triangle_set &mesh, const std
// solve at all, and why this also serves as the fallback for a chart LSCM cannot handle.
std::vector project_to_tangent_plane(const indexed_triangle_set &chart, const Vec3f &normal)
{
- const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX();
- const Vec3f u = Vec3f(seed.cross(normal).normalized());
- const Vec3f v = Vec3f(normal.cross(u).normalized());
+ // Oriented like the box projection (see box_projection_axes()), so a chart reads the right way up
+ // and unmirrored instead of being flattened to whichever basis a cross product happened to give.
+ // For a chart whose normal *is* a world axis - a cube face, which is the common case once the
+ // seam angle has cut the patch along its sharp edges - this is exactly the box basis, so such a
+ // face unwraps to the same orientation Triplanar would have given it.
+ Vec3f u, v;
+ box_projection_axes(normal, u, v);
+ // The box axes lie in the tangent plane already when the normal is a world axis; for any other
+ // normal, u is squared up against it and v follows. u x v == -normal keeps the chart the way
+ // round a viewer outside the model sees it (a plain normal x u would mirror it).
+ u = u - normal * normal.dot(u);
+ if (u.norm() <= 1e-6f) {
+ // Out of reach for a unit normal - the box basis puts u perpendicular to the normal's own
+ // dominant axis, so the two can never be within 45 degrees of each other - but a caller
+ // handing over an unnormalized normal should still get a usable basis rather than a NaN.
+ const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX();
+ u = seed.cross(normal);
+ }
+ u = Vec3f(u.normalized());
+ v = Vec3f(u.cross(normal).normalized());
std::vector uvs(chart.vertices.size());
for (size_t i = 0; i < chart.vertices.size(); ++i)
@@ -1857,6 +2183,61 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap)
return islands;
}
+std::vector 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 per_state;
+ selector.get_facets(per_state);
+ indexed_triangle_set soup;
+ std::vector 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 out(dst_mesh.indices.size(), 0);
+ tbb::parallel_for(tbb::blocked_range(0, dst_mesh.indices.size()),
+ [&](const tbb::blocked_range &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 &islands)
{
if (islands.empty())
@@ -1959,6 +2340,110 @@ std::vector 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 compute_model_corner_uvs(const indexed_triangle_set &base_mesh,
+ const std::vector &model_uv,
+ const indexed_triangle_set &patch,
+ const std::vector &patch_source)
+{
+ if (patch.indices.empty() || model_uv.size() != base_mesh.indices.size() * 3 ||
+ patch_source.size() != patch.indices.size())
+ return {};
+
+ std::vector 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 collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector &corner)
+{
+ if (patch.indices.empty() || corner.size() != patch.indices.size() * 3)
+ return {};
+
+ std::vector per_vertex(patch.vertices.size(), Vec2f::Zero());
+ std::vector 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 transfer_uv_channel(const indexed_triangle_set &src_mesh,
+ const std::vector &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::max();
+
+ std::vector out(dst_mesh.indices.size() * 3, Vec2f::Zero());
+ tbb::parallel_for(tbb::blocked_range(0, dst_mesh.indices.size()),
+ [&](const tbb::blocked_range &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
@@ -1968,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
@@ -1999,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;
@@ -2019,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;
@@ -2044,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;
}
@@ -2073,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);
@@ -2101,8 +2601,10 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
return texture.sample(uv, layer.tile_enabled, layer.tile_method);
}
// Flat projection onto the captured projector plane. Single-valued per point, so unlike
- // blended triplanar it is one sample, and it is what "project from view" places.
- return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up)));
+ // blended triplanar it is one sample, and it is what "project from view" places. The
+ // projector's up axis is negated because v runs *down* the image (v = 0 is its first pixel
+ // row), the same reason the projective branch above measures window y downward.
+ return sample_at(Vec2f(position.dot(layer.view_project_right), -position.dot(layer.view_project_up)));
case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar
case TextureProjectionMethod::Triplanar:
default: break;
@@ -2117,14 +2619,15 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
const float w_sum = w.x() + w.y() + w.z();
if (w_sum < 1e-8f)
// Degenerate normal: no axis is meaningfully dominant, so no blend is meaningful either.
- return sample_at(Vec2f(position.x(), position.y()));
+ return sample_at(box_plane_xy(position, normal.z()));
w /= w_sum;
- // Each plane drops the axis it is named for, matching project_planar()'s own convention (which
- // the GUI's on-canvas placement gizmo also relies on).
- return w.x() * sample_at(Vec2f(position.y(), position.z())) +
- w.y() * sample_at(Vec2f(position.x(), position.z())) +
- w.z() * sample_at(Vec2f(position.x(), position.y()));
+ // Each plane drops the axis it is named for and is oriented for the side of that plane the normal
+ // points to, matching project_planar()'s own convention (which the GUI's on-canvas placement
+ // gizmo also relies on). See box_plane_yz() for why the orientation is not simply (y, z).
+ return w.x() * sample_at(box_plane_yz(position, normal.x())) +
+ w.y() * sample_at(box_plane_xz(position, normal.y())) +
+ w.z() * sample_at(box_plane_xy(position, normal.z()));
}
bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer,
@@ -2138,8 +2641,12 @@ 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);
};
@@ -2157,6 +2664,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
};
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);
@@ -2190,7 +2699,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method);
return true;
} else {
- const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up));
+ // Negated up, exactly as sample_layer_height() does it.
+ const Vec2f p(position.dot(layer.view_project_right), -position.dot(layer.view_project_up));
if (!covered(p))
return false;
out = sample_at(p);
@@ -2206,7 +2716,7 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
Vec3f w = triplanar_weights(normal);
const float w_sum = w.x() + w.y() + w.z();
if (w_sum < 1e-8f) {
- const Vec2f p(position.x(), position.y());
+ const Vec2f p = box_plane_xy(position, normal.z());
if (!covered(p))
return false;
out = sample_at(p);
@@ -2217,8 +2727,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla
// A blend of three planes is only "not covered" where *every* contributing plane is outside the
// placement; where some are, the covered ones are renormalised so the colour does not fade toward
// black at the edge of an untiled tri-planar layer.
- const std::array planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()),
- Vec2f(position.x(), position.y()) };
+ const std::array planes = { box_plane_yz(position, normal.x()), box_plane_xz(position, normal.y()),
+ box_plane_xy(position, normal.z()) };
Vec3f acc = Vec3f::Zero();
float acc_w = 0.f;
for (int i = 0; i < 3; ++i)
@@ -2501,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 *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
@@ -2673,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**
@@ -2805,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 *model_uv)
{
// The classic path moves the vertices the mesh already has, so there is no budget to report on.
(void) stats;
@@ -2842,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).
@@ -2960,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 patch_source; // sub-triangle -> base mesh triangle, only built when colouring
+ std::vector 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
@@ -2997,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 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 model_corner_uvs = model_uv_layer ?
+ compute_model_corner_uvs(mesh, *model_uv, patch, patch_source) :
+ std::vector{};
+ const std::vector patch_uvs = model_uv_layer ? collapse_corner_uvs(patch, model_corner_uvs) :
+ (layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_uvs(patch, *layer) :
std::vector{};
// 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 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 patch_corner_uvs = model_uv_layer ? model_corner_uvs :
+ (layer->projection_method == TextureProjectionMethod::LSCM) ?
compute_lscm_corner_uvs(patch, *layer) :
std::vector{};
- 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
@@ -3038,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()))
@@ -3046,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;
}
@@ -3122,7 +3648,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
[&](const tbb::blocked_range &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
@@ -3255,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 *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
@@ -3265,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
@@ -3279,8 +3806,10 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
v = (frame * v.cast()).cast();
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)
@@ -3352,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 &movable, float strength,
@@ -3416,16 +3946,18 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector
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 corner; // compute_lscm_corner_uvs(patch, layer)
+ // compute_lscm_corner_uvs(patch, layer), or compute_model_corner_uvs() for a ModelUV layer
+ std::vector 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.
@@ -3438,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;
@@ -3505,7 +4031,7 @@ struct PreparedLayer {
// where a layer is placed. `need_color` additionally drops layers that cannot contribute colour.
std::shared_ptr> prepare_sampleable_layers(
const indexed_triangle_set &base_mesh, const std::vector &layers,
- const TextureDisplacementFacetsData &facets_data, bool need_color)
+ const TextureDisplacementFacetsData &facets_data, bool need_color, const std::vector *model_uv)
{
auto prepared = std::make_shared>();
@@ -3540,9 +4066,14 @@ std::shared_ptr> prepare_sampleable_layers(
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 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;
@@ -3552,12 +4083,16 @@ std::shared_ptr> prepare_sampleable_layers(
texture_displacement_patch_frame(patch, vertex_normals, centroid, axis, average_normal);
std::shared_ptr 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();
- 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);
@@ -3583,11 +4118,12 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
const std::vector &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize,
- ColorQuantizeFn quantize_pure)
+ ColorQuantizeFn quantize_pure,
+ const std::vector *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
@@ -3618,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 &layers,
- const TextureDisplacementFacetsData &facets_data)
+ const TextureDisplacementFacetsData &facets_data,
+ const std::vector *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;
diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp
index 48fb4f20cf..913918c825 100644
--- a/src/libslic3r/TextureDisplacement.hpp
+++ b/src/libslic3r/TextureDisplacement.hpp
@@ -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`.
@@ -154,6 +170,38 @@ struct TextureIsland
// chart to its true mm area, so a freshly unwrapped patch has uniform texel density to begin with.
void average_island_scales(std::vector &islands);
+// Photo-style adjustments to a layer's source image, as the gizmo's image-adjustment panel sets them.
+//
+// These are *not* layer parameters: nothing here is stored on TextureDisplacementLayer and nothing here is
+// re-applied at bake time. The panel applies them once, to the image, and the result becomes the layer's
+// image - so the adjusted pixels feed the colour and the displacement together, and a brightness change
+// really does change the relief. That is the intended behaviour: one image, one source of truth, rather
+// than a second set of knobs the bake would have to replay in the right order.
+//
+// Contrast with TextureDisplacementLayer::smoothing, which *is* a layer parameter applied on every decode:
+// `blur` here is a destructive edit of the image, `smoothing` is a live low-pass over whatever the image is.
+struct TextureAdjustments
+{
+ float brightness = 0.f; // [-1, 1]; 0 unchanged, -1 black, +1 white
+ float contrast = 0.f; // [-1, 1]; 0 unchanged, -1 flat mid-grey, +1 hard threshold
+ float sharpness = 0.f; // [0, 1]; 0 unchanged. Unsharp-mask amount
+ float blur = 0.f; // [0, 1]; 0 unchanged. Fraction of SOURCE_BLUR_SPAN, as `smoothing` is
+ float hue_deg = 0.f; // [-180, 180]; 0 unchanged. No effect on a grayscale image - grey has no hue
+
+ bool identity() const
+ {
+ return brightness == 0.f && contrast == 0.f && sharpness == 0.f && blur == 0.f && hue_deg == 0.f;
+ }
+ // Exact equality, which is what is wanted: these come straight off sliders, and the question asked of
+ // them is "is this still the value the preview on screen was computed from", not "are these close".
+ bool operator==(const TextureAdjustments &o) const
+ {
+ return brightness == o.brightness && contrast == o.contrast && sharpness == o.sharpness &&
+ blur == o.blur && hue_deg == o.hue_deg;
+ }
+ bool operator!=(const TextureAdjustments &o) const { return !(*this == o); }
+};
+
// One texture asset plus its projection/displacement parameters. Several layers may be painted
// onto overlapping areas of the same volume: their displacements are combined per vertex, in slot
// order, each layer folding into the total via its own TextureBlendMode (see
@@ -277,6 +325,18 @@ struct TextureDisplacementLayer
// - zero or positive: a mesh vertex, every unwrapped copy of it (how edits were stored before).
std::vector> lscm_uv_overrides;
+ // Live, unsaved image adjustments from the gizmo's image-adjustment panel, applied by
+ // decode_height_texture() on top of the decoded image - so while the panel is open the relief, the
+ // colour, the thumbnails and the bake all see the adjusted image, and moving a slider really does
+ // change the displacement. See TextureAdjustments.
+ //
+ // Deliberately *not* serialized, by either save()/load() here or the .3mf: it is the state of an open
+ // editor, not a property of the layer. The panel resets it when it closes, and "Save as copy" resolves
+ // it by writing a new image and pointing the layer at that - which is the only way an adjustment
+ // becomes permanent. A project saved mid-adjustment therefore stores the unadjusted image, which is
+ // the honest answer: nothing was committed.
+ TextureAdjustments preview_adjust;
+
// How this layer folds into the displacement accumulated by the layers below it. Ignored for
// the lowest-slot painted layer, which has nothing beneath it to combine with (the GUI shows
// it as the "Base" layer and hides the control).
@@ -541,6 +601,36 @@ struct DecodedHeightTexture
bool texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method, TexelTap &out) const;
};
+// Applies `adj` to `src` and returns the result; `src` is left alone. An empty `src`, or an identity
+// `adj`, gives back a copy.
+//
+// Order is tonal, then spatial: brightness, contrast and hue per texel first, then blur, then sharpness.
+// Sharpness last is what lets the two spatial knobs be used together - blur to kill speckle, then
+// sharpness to put the real edges back - instead of the blur simply undoing the sharpen.
+//
+// Grayscale in, grayscale out (`rgb` stays empty): promoting a grey image to RGB would hand the layer's
+// colour feature a row of identical greys to "colour" the model with, and triple the stored PNG for it.
+// For a colour image the height is recomputed as the luminance of the *adjusted* colour, with
+// decode_height_texture()'s own coefficients, so the relief and the colour can never drift apart.
+DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj);
+
+// Blur radius, in texels, that TextureAdjustments::blur == 1 means on a `min(width, height)`-sized image:
+// the same 5%-of-the-short-side span the Smoothing slider uses, under the same 48-texel cost cap, so the
+// two controls bite at the same rate and a user moving between them is not surprised.
+float adjustment_blur_radius(float blur, int width, int height);
+
+// Box-filters `src` down so neither side exceeds `max_px`, preserving aspect; never upscales, and
+// returns a copy when it already fits. Colour and height are filtered the same way, and the height
+// stays the height - it is not recomputed from the filtered colour, because a box filter is linear and
+// luminance is linear in the channels, so the two agree already.
+//
+// This is what lets the adjustment panel's live preview cost depend on the size of the box it is drawn
+// in rather than on the size of the image: adjusting a downscaled copy per slider frame, and the
+// full-size image only once, on commit. A box filter (rather than a plain decimation) is the point -
+// these textures are high-frequency by nature, and dropping texels would alias the preview into
+// showing detail the adjustment is not actually producing.
+DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px);
+
// Decode a layer's raw image bytes into sampleable height data, plus colour when the source has any.
// Both 8-bit grayscale PNGs (the shipped library, and anything imported before colour was kept) and
// colour PNGs are accepted; for a colour source the height is its luminance, using the same
@@ -601,11 +691,24 @@ struct TextureColorSettings
bool empty() const { return palette.empty(); }
};
+// The two world directions the box projection takes its u and v along on a face whose outward
+// normal is `normal`: the plane is the one that normal is closest to, and the pair is oriented as a
+// viewer standing outside the model sees it - u to their right, v *down* the image, which is the
+// direction v runs in the texture (v = 0 is the image's first pixel row, see
+// DecodedHeightTexture::sample()). So an image comes out the way it looks in a file manager rather
+// than upside down, and it is not mirrored on the three faces facing the other way.
+//
+// Exposed because the GUI needs the same basis for the on-canvas "adjust texture placement" gizmo
+// (its ring's zero angle and its offset handle's plane), and it must not be a second, drifting copy
+// of the table.
+void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis);
+
// Raw dominant-axis planar projection of `position` (in mm, not yet scaled/rotated/offset by any
-// layer), dropping the axis position that best aligns with `normal`. Exposed on its own (rather
-// than only inline inside project_texture_displacement_uv()) so GUI code - the on-canvas
-// "adjust texture placement" gizmo - can map a dragged 3D point into the exact same 2D space
-// tiling_scale/rotation_deg/offset operate in, without duplicating the axis-selection logic.
+// layer), dropping the axis position that best aligns with `normal`. Equivalent to projecting onto
+// box_projection_axes()' pair. Exposed on its own (rather than only inline inside
+// project_texture_displacement_uv()) so GUI code - the on-canvas "adjust texture placement" gizmo -
+// can map a dragged 3D point into the exact same 2D space tiling_scale/rotation_deg/offset operate
+// in, without duplicating the axis-selection logic.
Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// Applies a layer's tiling_scale/rotation_deg/offset to an already-projected planar coordinate
@@ -618,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 -
@@ -655,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 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
@@ -768,6 +902,55 @@ std::vector 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 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 compute_model_corner_uvs(const indexed_triangle_set &base_mesh,
+ const std::vector &model_uv,
+ const indexed_triangle_set &patch,
+ const std::vector &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 collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector &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 transfer_uv_channel(const indexed_triangle_set &src_mesh,
+ const std::vector &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); }
@@ -826,6 +1009,10 @@ using TextureDisplacementFacetsData = std::array palette index. Supplied by the GUI, which owns both the perceptual matching and the list
@@ -900,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 *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
@@ -975,11 +1164,15 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
const std::vector &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 *model_uv = nullptr);
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector &layers,
- const TextureDisplacementFacetsData &facets_data);
+ const TextureDisplacementFacetsData &facets_data,
+ const std::vector *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
@@ -1184,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 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;
diff --git a/src/libslic3r/TexturePainting.cpp b/src/libslic3r/TexturePainting.cpp
index 1bcddfd3d2..2f217b1aa2 100644
--- a/src/libslic3r/TexturePainting.cpp
+++ b/src/libslic3r/TexturePainting.cpp
@@ -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 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
diff --git a/src/libvgcode/src/ViewerImpl.cpp b/src/libvgcode/src/ViewerImpl.cpp
index 8dc908d6d8..c6fcb01ace 100644
--- a/src/libvgcode/src/ViewerImpl.cpp
+++ b/src/libvgcode/src/ViewerImpl.cpp
@@ -927,15 +927,17 @@ void ViewerImpl::reset()
m_used_extruders.clear();
m_total_time = { 0.0f, 0.0f };
m_travels_time = { 0.0f, 0.0f };
- m_vertices.clear();
- m_vertices_colors.clear();
// swap rather than clear: these are sized by the print, and a reset means the memory
// should go back, not sit reserved until the next load
+ std::vector().swap(m_vertices);
+ std::vector().swap(m_vertices_colors);
for (std::vector& times : m_layer_start_times)
std::vector().swap(times);
std::vector().swap(m_layer_first_vertex);
m_layers_in_vertex_order = false;
- m_valid_lines_bitset.clear();
+ // BitSet::clear() only zeroes the bits, it keeps the blocks allocated; load() builds a new
+ // bitset anyway and it is never read while m_vertices is empty
+ m_valid_lines_bitset = BitSet<>();
#if VGCODE_ENABLE_COG_AND_TOOL_MARKERS
m_cog_marker.reset();
#endif // VGCODE_ENABLE_COG_AND_TOOL_MARKERS
@@ -1834,6 +1836,7 @@ size_t ViewerImpl::get_used_cpu_memory() const
ret += sizeof(m_extrusion_roles_colors);
ret += sizeof(m_options_colors);
ret += STDVEC_MEMSIZE(m_vertices, PathVertex);
+ ret += STDVEC_MEMSIZE(m_vertices_colors, float);
for (const std::vector& times : m_layer_start_times)
ret += STDVEC_MEMSIZE(times, float);
ret += STDVEC_MEMSIZE(m_layer_first_vertex, uint32_t);
diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp
index c509e5d6f8..2e429dda20 100644
--- a/src/slic3r/GUI/GLCanvas3D.cpp
+++ b/src/slic3r/GUI/GLCanvas3D.cpp
@@ -1514,6 +1514,8 @@ wxDEFINE_EVENT(EVT_GLCANVAS_PRINTABLE, SimpleEvent);
const double GLCanvas3D::DefaultCameraZoomToBoxMarginFactor = 1.25;
const double GLCanvas3D::DefaultCameraZoomToBedMarginFactor = 2.00;
const double GLCanvas3D::DefaultCameraZoomToPlateMarginFactor = 1.25;
+// Main-thread only: _render_frame never runs off it, so no atomic is needed.
+int GLCanvas3D::s_frames_in_progress = 0;
void GLCanvas3D::load_arrange_settings()
{
@@ -2468,7 +2470,8 @@ void GLCanvas3D::_render_frame(bool scene_dirty, bool only_init)
}
m_in_render = true;
- Slic3r::ScopeGuard in_render_guard([this]() { m_in_render = false; });
+ ++s_frames_in_progress;
+ Slic3r::ScopeGuard in_render_guard([this]() { m_in_render = false; --s_frames_in_progress; });
(void)in_render_guard;
if (m_canvas == nullptr)
@@ -2713,15 +2716,31 @@ void GLCanvas3D::_render_frame(bool scene_dirty, bool only_init)
// On Wayland, eglSwapBuffers blocks when the canvas is hidden or
// occluded. Skip the swap to avoid stalling the render loop.
- if (m_canvas->IsShownOnScreen()) {
+ // Code run from inside this frame - a modal dialog a gizmo opened from its ImGui panel, a
+ // synchronous paint of another canvas - may have made a different surface current. Bind this one
+ // again before presenting, so the swap is not issued against a window the context no longer
+ // belongs to: on Mesa/X11 that is a fatal BadMatch, not the no-op it is on other platforms. Draws
+ // already issued before this point cannot be redirected - only this swap is protected.
+ if (m_canvas->IsShownOnScreen() && _set_current()) {
m_canvas->SwapBuffers();
m_frame_profiler.mark("swap");
if (!overlay_tick)
m_render_stats.increment_fps_counter();
m_presented_signature = signature;
}
- else
+ else {
+ if (m_canvas->IsShownOnScreen()) {
+ // Logged once: if the rebind fails it does so every frame, and a warning per frame would
+ // bury everything else.
+ static bool rebind_failure_logged = false;
+ if (!rebind_failure_logged) {
+ rebind_failure_logged = true;
+ BOOST_LOG_TRIVIAL(warning) << "GLCanvas3D: could not rebind the GL context to this canvas before "
+ "SwapBuffers(); the frame is not presented";
+ }
+ }
m_presented_signature.reset();
+ }
}
// Everything drawn into the 3D scene, from the clear to the post processing passes, ending in the
diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp
index bb148234d1..2cb6bcea4f 100644
--- a/src/slic3r/GUI/GLCanvas3D.hpp
+++ b/src/slic3r/GUI/GLCanvas3D.hpp
@@ -643,6 +643,8 @@ private:
#endif
unsigned int m_last_w, m_last_h;
bool m_in_render;
+ // How many canvases are inside _render_frame() right now - see is_frame_in_progress().
+ static int s_frames_in_progress;
wxTimer m_timer;
wxTimer m_timer_set_color;
// Armed by each frame that draws the FPS overlay; its tick requests an overlay-only frame.
@@ -974,6 +976,11 @@ public:
void on_change_color_mode(bool is_dark, bool reinit = true);
const bool get_dark_mode_status() { return m_is_dark; }
void set_as_dirty() { m_dirty = true; }
+ // True while any GLCanvas3D is inside _render_frame(). The other surfaces that share the app's one
+ // GL context (the UV editor canvas) check this before making themselves current: a frame interrupted
+ // by another surface finishes drawing into that surface and then presents a window the context no
+ // longer belongs to, which on Mesa/X11 is a fatal X error (BadMatch) rather than a no-op.
+ static bool is_frame_in_progress() { return s_frames_in_progress > 0; }
void requires_check_outside_state() { m_requires_check_outside_state = true; }
unsigned int get_volumes_count() const { return (unsigned int)m_volumes.volumes.size(); }
diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp
index 14c04cbc39..edce3fec5d 100644
--- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp
+++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp
@@ -43,6 +43,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -120,6 +121,14 @@ constexpr int THUMBNAIL_MAX_PX = 128;
// blur of it - which is what made Fast look flatter and softer than the result it was previewing.
constexpr int HEIGHT_TEX_MAX_PX = 2048;
+// Size the image-adjustment panel's live preview is computed and uploaded at. The panel draws the image
+// in a box about 240 px wide, so there is nothing to show above this - and the preview is recomputed on
+// every slider move, which is what makes its cost worth bounding. Measured on a 1024x1024 with every
+// knob engaged, a full-size pass is 13-19 ms grayscale / 40-60 ms in colour (130-190 ms at 2048 in
+// colour); the same pass on a 384 px copy is 3-4 ms / 9-12 ms, and it runs off the UI thread besides.
+// The full-size image is only ever adjusted once, on commit.
+constexpr int ADJUST_PREVIEW_MAX_PX = 384;
+
// How many rings of vertex-adjacent triangles either side of the paint's edge join the border refine
// band (see collect_paint_region()). Two is enough to grade the size change without the band's own
// cost growing to matter: it is a ring around a perimeter, not an area.
@@ -543,6 +552,10 @@ void GLGizmoTextureDisplacement::on_shutdown()
// progress poll, and its completion handler then finds nothing to do.
m_preview_generation->fetch_add(1);
m_preview_job_pending = false;
+ // The adjustment panel holds a full-size decode of its layer's image, which is the largest thing
+ // here; a closed gizmo should not keep it alive. Discards the edit, as closing the gizmo on an open
+ // panel implies.
+ close_image_adjust();
// The palette caches hold the last volume's images; a closed gizmo should not keep them alive.
m_palette_cache.clear();
m_palette_quantizer = nullptr;
@@ -1255,9 +1268,33 @@ indexed_triangle_set GLGizmoTextureDisplacement::patch_in_world(const indexed_tr
return world;
}
-std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch,
- const TextureDisplacementLayer &layer) const
+std::vector GLGizmoTextureDisplacement::model_uv_corners(const indexed_triangle_set &local_patch,
+ const TextureDisplacementLayer &layer,
+ const std::vector *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 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 GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch,
+ const TextureDisplacementLayer &layer,
+ const std::vector *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 -
@@ -1286,8 +1323,9 @@ std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in
uv[vi] = Vec2f(-1e6f, -1e6f);
continue;
}
+ // Negated up, as sample_layer_height()'s affine branch has it: v runs down the image.
const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right),
- patch.vertices[vi].dot(layer.view_project_up));
+ -patch.vertices[vi].dot(layer.view_project_up));
uv[vi] = apply_uv_transform(planar, layer, aspect);
}
return uv;
@@ -1317,8 +1355,11 @@ int GLGizmoTextureDisplacement::layer_projection_frame(const indexed_triangle_se
}
std::vector GLGizmoTextureDisplacement::compute_layer_corner_uvs(const indexed_triangle_set &local_patch,
- const TextureDisplacementLayer &layer) const
+ const TextureDisplacementLayer &layer,
+ const std::vector *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);
@@ -1328,7 +1369,7 @@ std::vector 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 per_vertex = compute_layer_vertex_uvs(local_patch, layer);
+ const std::vector per_vertex = compute_layer_vertex_uvs(local_patch, layer, patch_source);
if (per_vertex.size() != local_patch.vertices.size())
return {};
std::vector corner(local_patch.indices.size() * 3);
@@ -1349,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 patch_source;
+ const indexed_triangle_set patch =
+ m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source);
if (patch.indices.empty())
return;
@@ -1373,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 corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active) : std::vector{};
+ std::vector corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active, &patch_source) :
+ std::vector{};
m_shaded_preview_uses_vertex_uv = corner_uv.size() == patch.indices.size() * 3;
if (!m_shaded_preview_uses_vertex_uv)
corner_uv.clear();
@@ -2043,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 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
@@ -2057,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 uv = compute_layer_vertex_uvs(patch, *layer);
+ std::vector 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 :
@@ -2066,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 corner_uv = compute_layer_corner_uvs(patch, *layer);
+ const std::vector 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.
@@ -2342,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
@@ -2497,9 +2547,12 @@ void GLGizmoTextureDisplacement::update_uv_editor()
// before, and independently of, the unwrap - the texture belongs on screen before anything is unwrapped - and
// only when the choice, the layer's image or its smoothing changed, because the height image is large.
const UVBackground desired_bg = (m_uv_check_mode == UVCheckMode::Checker) ? UVBackground::Checker : UVBackground::Height;
- const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) && (m_uv_editor_bg_smoothing != layer->smoothing);
+ const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) &&
+ (m_uv_editor_bg_smoothing != layer->smoothing ||
+ !(m_uv_editor_bg_adjust == layer->preview_adjust));
if (desired_bg != m_uv_editor_bg || layer->image_data.get() != m_uv_editor_bg_image || bg_smoothing_changed) {
m_uv_editor_bg_smoothing = layer->smoothing;
+ m_uv_editor_bg_adjust = layer->preview_adjust;
m_uv_editor_bg_image = layer->image_data.get();
m_uv_editor_bg = desired_bg;
if (desired_bg == UVBackground::Checker) {
@@ -3371,19 +3424,10 @@ Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacement
void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const
{
- // Mirrors project_planar()'s own dominant-axis choice exactly, so the ring's "0 degrees" and
- // the offset handle's plane always agree with what project_texture_displacement_uv() does.
- const Vec3f n = m_adjust_anchor_normal.cwiseAbs();
- if (n.x() >= n.y() && n.x() >= n.z()) {
- u_axis = Vec3f::UnitY();
- v_axis = Vec3f::UnitZ();
- } else if (n.y() >= n.x() && n.y() >= n.z()) {
- u_axis = Vec3f::UnitX();
- v_axis = Vec3f::UnitZ();
- } else {
- u_axis = Vec3f::UnitX();
- v_axis = Vec3f::UnitY();
- }
+ // libslic3r's own basis, asked for rather than restated here, so the ring's "0 degrees" and the
+ // offset handle's plane always agree with what project_texture_displacement_uv() does - including
+ // v pointing *down* the texture and u flipping on the far side of each plane.
+ box_projection_axes(m_adjust_anchor_normal, u_axis, v_axis);
}
void GLGizmoTextureDisplacement::render_adjust_texture_gizmo()
@@ -3785,11 +3829,13 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
"texture_displacement_checker.svg", "texture_displacement_distortion.svg",
"texture_displacement_wireframe.svg", "texture_displacement_cross.svg",
"texture_displacement_uv_select_island.svg", "texture_displacement_uv_select_edge.svg",
+ "cog.svg", // the layer card's "adjust image" button
"texture_displacement_uv_select_vertex.svg",
// 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",
@@ -3838,6 +3884,11 @@ 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.
@@ -4117,6 +4168,338 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
m_picker_open_request = true;
}
}
+bool GLGizmoTextureDisplacement::load_image_adjust_source(const TextureDisplacementLayer &layer)
+{
+ if (layer.empty())
+ return false;
+
+ // The *raw* image: neither the layer's Smoothing nor the panel's own preview_adjust, so what the
+ // sliders act on is always the image as it is actually stored rather than the last preview of it.
+ // Without clearing preview_adjust here the adjustment would compound on itself every reload.
+ TextureDisplacementLayer raw = layer;
+ raw.smoothing = 0.f;
+ raw.preview_adjust = TextureAdjustments{};
+
+ DecodedHeightTexture decoded = decode_height_texture(raw);
+ if (decoded.empty())
+ return false;
+
+ m_adjust_source = std::move(decoded);
+ m_adjust_preview_source = downscale_height_texture(m_adjust_source, ADJUST_PREVIEW_MAX_PX);
+ m_adjust_source_image = layer.image_data.get();
+ // The unadjusted image, so the box is filled now rather than one background pass later. Pairing it
+ // with an identity `shown` is what makes update_image_adjust_preview() relaunch for the sliders'
+ // current values against the new source instead of deciding it is already showing them.
+ m_adjust_preview_shown = TextureAdjustments{};
+ m_adjust_preview_tex = upload_height_thumbnail(m_adjust_preview_source, ADJUST_PREVIEW_MAX_PX);
+ m_adjust_preview_valid = m_adjust_preview_tex != nullptr;
+ return true;
+}
+
+void GLGizmoTextureDisplacement::open_image_adjust(const TextureDisplacementLayer &layer)
+{
+ if (layer.empty() || layer.slot < 0)
+ return;
+
+ close_image_adjust(); // reverts and clears any previous panel
+
+ if (!load_image_adjust_source(layer))
+ return;
+
+ m_adjust = TextureAdjustments{};
+ m_adjust_applied = TextureAdjustments{};
+ m_adjust_slot = layer.slot;
+ const ModelVolume *mv = texture_volume();
+ m_adjust_volume_id = mv != nullptr ? mv->id().id : 0;
+}
+
+void GLGizmoTextureDisplacement::invalidate_adjust_textures()
+{
+ // The GPU copies of a layer's image - the list thumbnail, the preview shader's height and colour
+ // textures - are keyed on the image_data pointer and the layer's Smoothing, and a live adjustment
+ // changes neither: the pointer still names the same PNG. Rather than widen three cache keys for a
+ // panel that is open a few seconds at a time, the panel drops the copies itself whenever it moves
+ // the adjustment. Resetting the source is enough; the next get_layer_*() rebuilds from the decode,
+ // which does see the adjustment.
+ m_thumbnail_source.fill({});
+ m_color_tex_source.reset();
+ m_height_tex_source.reset();
+}
+
+void GLGizmoTextureDisplacement::close_image_adjust()
+{
+ // Revert the layer's live adjustment. Every exit that reaches here is a discard: a commit resets
+ // preview_adjust itself, before pointing the layer at the file that already carries the edit, so this
+ // cannot undo a committed adjustment.
+ //
+ // Found by the id recorded when the panel opened rather than through texture_volume(), which answers
+ // for the *current* selection. One of the ways to get here is the selection having moved on, and
+ // reverting the newly selected object's layer instead would both miss the adjustment that is still
+ // live on the original volume and clear one the user never touched.
+ bool reverted = false;
+ if (m_adjust_slot >= 0 && m_adjust_volume_id != 0)
+ for (ModelObject *mo : wxGetApp().model().objects) {
+ for (ModelVolume *mv : mo->volumes) {
+ if (mv->id().id != m_adjust_volume_id)
+ continue;
+ for (TextureDisplacementLayer &l : mv->texture_displacement_layers)
+ if (l.slot == m_adjust_slot && !l.preview_adjust.identity()) {
+ l.preview_adjust = TextureAdjustments{};
+ reverted = true;
+ }
+ }
+ }
+
+ if (reverted)
+ invalidate_adjust_textures();
+
+ // std::async's future blocks here until the pass finishes. That is a few milliseconds on an image
+ // this small, and the alternative - a detached thread writing into the members of a closed panel -
+ // is worse.
+ if (m_adjust_preview_job.valid())
+ m_adjust_preview_job.wait();
+ m_adjust_preview_job = {};
+
+ m_adjust_slot = -1;
+ m_adjust = TextureAdjustments{};
+ m_adjust_applied = TextureAdjustments{};
+ m_adjust_source = DecodedHeightTexture{};
+ m_adjust_preview_source = DecodedHeightTexture{};
+ m_adjust_source_image = nullptr;
+ m_adjust_volume_id = 0;
+ m_adjust_preview_tex.reset();
+ m_adjust_preview_valid = false;
+
+ // Only while the gizmo is up: on_shutdown() also comes through here, and rebuilding a preview for a
+ // gizmo that is closing would be work for a model nobody is looking at. The reverted layer data is
+ // what matters there, and that is already done above.
+ if (reverted && m_state == On) {
+ rebuild_preview();
+ m_parent.set_as_dirty();
+ }
+}
+
+void GLGizmoTextureDisplacement::update_image_adjust_preview()
+{
+ if (m_adjust_preview_source.empty())
+ return;
+
+ // Collect a finished pass. wait_for(0) rather than get() outright: this runs inside the frame, and
+ // blocking on the worker here would hand back to the UI thread the very stall the worker exists to
+ // spare it.
+ if (m_adjust_preview_job.valid() &&
+ m_adjust_preview_job.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
+ const DecodedHeightTexture adjusted = m_adjust_preview_job.get();
+ m_adjust_preview_job = {};
+ if (std::unique_ptr tex = upload_height_thumbnail(adjusted, ADJUST_PREVIEW_MAX_PX)) {
+ m_adjust_preview_tex = std::move(tex);
+ m_adjust_preview_shown = m_adjust_preview_running;
+ m_adjust_preview_valid = true;
+ }
+ }
+
+ // One pass at a time: whatever the sliders did while it ran is picked up by the next launch, so a
+ // drag collapses to the value the sliders ended on instead of queuing one pass per frame.
+ if (!m_adjust_preview_job.valid() && m_adjust != m_adjust_preview_shown) {
+ m_adjust_preview_running = m_adjust;
+ // Captured by value: the pass must not read members the UI thread goes on editing while it runs.
+ m_adjust_preview_job = std::async(std::launch::async,
+ [src = m_adjust_preview_source, adj = m_adjust_preview_running] {
+ return apply_texture_adjustments(src, adj);
+ });
+ }
+
+ // A pass in flight has no other way to get its result on screen: without a redraw request the canvas
+ // can sit idle, and the preview would only catch up on the next unrelated frame.
+ if (m_adjust_preview_job.valid())
+ m_parent.set_as_dirty();
+}
+
+void GLGizmoTextureDisplacement::commit_image_adjust(TextureDisplacementLayer &layer)
+{
+ if (m_adjust_source.empty() || m_adjust.identity()) {
+ // Nothing was changed, so there is nothing to save.
+ close_image_adjust();
+ return;
+ }
+
+ // The full-size pass, once: 13-19 ms grayscale / 40-60 ms colour on a 1024x1024 with every knob
+ // engaged. Run on the UI thread deliberately, unlike the live preview: this is a single click, and the
+ // rebuild that follows it - a CPU mesh displacement of the painted patch - is already an order of
+ // magnitude more work, synchronously, so moving only this off the thread would buy nothing a user
+ // could perceive.
+ const DecodedHeightTexture adjusted = apply_texture_adjustments(m_adjust_source, m_adjust);
+
+ std::string error;
+ const std::string stem = layer.name.empty() ? std::string("texture") : layer.name;
+ const std::optional entry = save_texture_to_library(adjusted, stem, error);
+ if (!entry) {
+ show_error(nullptr, error);
+ return; // the panel stays open, and the layer keeps the live adjustment: the edit is not lost
+ }
+
+ // Cleared *before* the layer is pointed at the new file, and this ordering is the whole correctness of
+ // the commit: the saved image already has the adjustment baked into its pixels, so leaving
+ // preview_adjust set would apply brightness, contrast and the rest a second time on top of itself.
+ layer.preview_adjust = TextureAdjustments{};
+ // set_layer_texture() does the rest - the name, the recently-used ordering, a fresh image_data buffer
+ // (which is what invalidates every cache keyed by the old pointer), the undo snapshot and the rebuild.
+ set_layer_texture(layer, *entry);
+
+ close_image_adjust();
+}
+
+void GLGizmoTextureDisplacement::render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max)
+{
+ if (m_adjust_slot < 0)
+ return;
+
+ ModelVolume *mv = texture_volume();
+ // A different volume is selected than the one this panel was opened on. Slots are numbered per
+ // volume, so looking m_adjust_slot up here would find an unrelated layer rather than nothing.
+ if (mv == nullptr || mv->id().id != m_adjust_volume_id) {
+ close_image_adjust();
+ return;
+ }
+
+ TextureDisplacementLayer *layer = nullptr;
+ for (TextureDisplacementLayer &l : mv->texture_displacement_layers)
+ if (l.slot == m_adjust_slot)
+ layer = &l;
+ if (layer == nullptr) {
+ // The layer went away under the panel (removed, or merged away by an undo).
+ close_image_adjust();
+ return;
+ }
+
+ // The layer's image can be swapped from the texture picker while this panel is open - the panel is a
+ // window now, so the rest of the gizmo stays usable behind it. Re-read the source when that happens,
+ // keeping the slider positions: the user is adjusting this layer, and the 3D view has already moved to
+ // the new image (decode_height_texture() reads layer.image_data, which changed), so re-reading here is
+ // also what keeps the panel's photo and the model showing the same thing.
+ if (layer->image_data.get() != m_adjust_source_image && !load_image_adjust_source(*layer)) {
+ close_image_adjust(); // the new image cannot be decoded; nothing left to adjust
+ return;
+ }
+
+ const float img_w = std::round(m_imgui->scaled(16.f));
+ const float gap = m_imgui->scaled(0.5f);
+ // Placed beside the panel on first appearance only, and left wherever the user drags it after that -
+ // it is a window, not a popup, so its position is theirs to keep.
+ const float win_w = img_w + 2.f * ImGui::GetStyle().WindowPadding.x;
+ const bool left = panel_min.x - gap - win_w >= 0.f;
+ ImGui::SetNextWindowPos(ImVec2(left ? panel_min.x - gap - win_w : panel_max.x + gap,
+ panel_min.y + m_imgui->scaled(4.f)), ImGuiCond_Appearing);
+
+ bool keep_open = true;
+ if (!ImGui::Begin((_u8L("Adjust image") + "##texture_adjust").c_str(), &keep_open,
+ ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) {
+ ImGui::End();
+ if (!keep_open)
+ close_image_adjust();
+ return;
+ }
+
+ // Collect a finished pass and start one for the current slider values before the image is drawn, so
+ // the newest available result is what lands on screen this frame.
+ update_image_adjust_preview();
+
+ // The photo itself, at the image's own aspect so a non-square texture is not stretched.
+ const float aspect = m_adjust_source.height > 0 ? float(m_adjust_source.width) / float(m_adjust_source.height) : 1.f;
+ const float img_h = std::round(img_w / std::max(0.05f, aspect));
+ if (m_adjust_preview_valid && m_adjust_preview_tex)
+ ImGui::Image((ImTextureID) (intptr_t) m_adjust_preview_tex->get_id(), ImVec2(img_w, img_h));
+ else
+ ImGui::Dummy(ImVec2(img_w, img_h));
+
+ // The label column is sized off the widest of the five labels, so the tracks line up whatever the
+ // translation does to their lengths.
+ float label_w = 0.f;
+ for (const wxString &l : { _L("Brightness"), _L("Contrast"), _L("Sharpness"), _L("Blur"), _L("Hue") })
+ label_w = std::max(label_w, ImGui::CalcTextSize(into_u8(l).c_str()).x);
+ label_w += m_imgui->scaled(1.2f);
+ // An explicit track width rather than a fill-to-the-right one: this window is AlwaysAutoResize, so its
+ // width comes from its content, and an item asking for "whatever is left" would make the width depend
+ // on itself. The image above sets the content width, and the sliders match it.
+ const float slider_w = std::max(m_imgui->scaled(4.f), img_w - label_w);
+
+ const auto row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max,
+ const char *format, bool enabled, const std::string &tip) {
+ const float x0 = ImGui::GetCursorPosX();
+ ImGui::AlignTextToFramePadding();
+ m_imgui->text(label);
+ ImGui::SameLine();
+ ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
+ ImGui::SetNextItemWidth(slider_w);
+ m_imgui->disabled_begin(!enabled);
+ ImGui::SliderFloat(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp);
+ m_imgui->disabled_end();
+ // Only on an enabled row: a disabled item does not report hover, so a tooltip on one would never
+ // be shown. What has to be said about a disabled slider is written out below it instead.
+ if (enabled && !tip.empty() && ImGui::IsItemHovered())
+ m_imgui->tooltip(tip, ImGui::GetFontSize() * 20.f);
+ };
+
+ row("##adj_brightness", _L("Brightness"), &m_adjust.brightness, -1.f, 1.f, "%.2f", true, {});
+ row("##adj_contrast", _L("Contrast"), &m_adjust.contrast, -1.f, 1.f, "%.2f", true, {});
+ row("##adj_sharpness", _L("Sharpness"), &m_adjust.sharpness, 0.f, 1.f, "%.2f", true,
+ _u8L("Brings out the finest detail in the image. Applied after Blur, so the two can be used "
+ "together: blur away the speckle, then sharpen the edges that remain."));
+ row("##adj_blur", _L("Blur"), &m_adjust.blur, 0.f, 1.f, "%.2f", true,
+ _u8L("Blurs the image itself. The layer's own Smoothing slider softens the relief without altering "
+ "the image, and is the one to reach for first."));
+ // Grey has no hue to turn, and the shipped library is grayscale - so on most textures this is a
+ // slider that would visibly do nothing. Disabled with the reason given, rather than hidden.
+ const bool has_color = m_adjust_source.has_color();
+ row("##adj_hue", _L("Hue"), &m_adjust.hue_deg, -180.f, 180.f, "%.0f°", has_color, {});
+ if (!has_color) {
+ ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + img_w);
+ ImGui::TextDisabled("%s", _u8L("This texture is grayscale, so it has no hue to shift.").c_str());
+ ImGui::PopTextWrapPos();
+ }
+
+ if (m_imgui->button(_L("Reset")))
+ m_adjust = TextureAdjustments{};
+
+ // Hand the sliders to the layer, which is what puts the adjusted image in front of the displacement:
+ // decode_height_texture() applies preview_adjust, so the relief, the colour and the thumbnails all
+ // follow from here. Written every frame rather than only on a change, so an undo snapshot or a
+ // reselection that resets the layer cannot leave the model showing an adjustment the panel has moved
+ // on from.
+ layer->preview_adjust = m_adjust;
+ if (m_adjust != m_adjust_applied) {
+ m_adjust_applied = m_adjust;
+ invalidate_adjust_textures();
+ // Marked dirty rather than rebuilt here: the panel's own flag is consumed by the main panel's
+ // "rebuild once the mouse is released" rule, so dragging a slider recomputes the displacement
+ // once, on release, instead of on every frame of the drag. That is what keeps the UI responsive
+ // while a full-size resample and a mesh displacement happen behind it.
+ m_preview_params_dirty = true;
+ // This window is drawn after the block that consumes that flag, so the rebuild happens on a later
+ // frame - which means there has to *be* one. Cheap insurance: a slider release that generated no
+ // further canvas event would otherwise leave the flag set and the model unchanged until something
+ // unrelated redrew.
+ m_parent.set_as_dirty();
+ }
+
+ ImGui::Separator();
+ m_imgui->text_wrapped(_u8L("The model updates when you release a slider. Save as copy writes the "
+ "adjusted image to your texture library and points this layer at it."), img_w);
+
+ const bool cancel = m_imgui->button(_L("Cancel"));
+ ImGui::SameLine();
+ // Nothing to save until a slider has actually moved.
+ m_imgui->disabled_begin(m_adjust.identity());
+ const bool as_copy = m_imgui->button(_L("Save as copy"));
+ m_imgui->disabled_end();
+
+ ImGui::End();
+
+ if (cancel || !keep_open)
+ close_image_adjust();
+ else if (as_copy)
+ commit_image_adjust(*layer);
+}
void GLGizmoTextureDisplacement::remove_texture_layer(int slot)
{
@@ -5013,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 &layers, const TextureDisplacementPrepareParams ¶ms,
+ const std::vector &layers, const std::vector &model_uv,
+ const TextureDisplacementPrepareParams ¶ms,
const TextureColorSettings &color_settings, const DisplacementProgressFn &progress,
BakeStageRecorder *debug)
{
@@ -5040,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;
@@ -5086,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 uv_stage = uv_now();
+ const std::vector *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;
@@ -5096,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
@@ -5155,8 +5548,10 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
std::vector 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 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 cut_source;
size_t cuts = 0;
indexed_triangle_set cut = cut_mesh_at_steps(mesh.its, cut_region, refined_sampler,
@@ -5180,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;
@@ -5895,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);
@@ -6723,7 +7121,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
const std::string not_painted = _u8L("not painted");
- const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 3.f * frame_h + 2.f * gap_s;
+ const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 4.f * frame_h + 3.f * gap_s;
const float name_w = std::max(frame_h, ImGui::GetWindowContentRegionMax().x - card_pad - ImGui::GetCursorPosX() - gap_s - right_w);
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(orca.x, orca.y, orca.z, 0.2f));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(orca.x, orca.y, orca.z, 0.35f));
@@ -6737,6 +7135,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
ImGui::TextDisabled("%s", not_painted.c_str());
}
+ // Between the name and the order arrows: it acts on the image the name refers to, so it
+ // belongs with the name rather than with the controls that reorder the stack.
+ ImGui::SameLine();
+ m_imgui->disabled_begin(busy || layer.empty());
+ if (icon_button(613 + layer.slot, "cog.svg", frame_h, _L("Adjust image"),
+ _u8L("Brightness, contrast, sharpness, blur and hue, shown on the image and on the "
+ "model as you set them. Save as copy writes the result to your texture library "
+ "and points this layer at it.")))
+ open_image_adjust(layer);
+ m_imgui->disabled_end();
ImGui::SameLine();
m_imgui->disabled_begin(busy || li == 0);
if (icon_button(611, "texture_displacement_move_up.svg", frame_h, _L("Move up"), _L("Move up - applied earlier"))) {
@@ -6765,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."));
@@ -6874,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();
@@ -6902,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(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.
@@ -7038,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(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(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();
@@ -7057,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);
@@ -7534,10 +7980,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
set_active_layer(m_picker_slot);
// In this window's scope, where the popup's open request and the popup itself share one ID stack.
+ ImVec2 panel_rect_min, panel_rect_max;
{
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));
+ // Only recorded here. The image-adjustment panel is a top-level window rather than a popup, so it
+ // cannot be begun inside this one; it is drawn after GizmoImguiEnd() below.
+ panel_rect_min = win_min;
+ panel_rect_max = ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y);
}
// ImGui sliders report "changed" continuously on every frame while being dragged, not just once on
@@ -7775,6 +8226,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_parent.request_extra_frame();
}
GizmoImguiEnd();
+
+ // Outside the gizmo's window, because it is a window of its own: a floating, non-modal panel that
+ // leaves the 3D view live so the relief can be watched while the sliders move. No-op when closed.
+ render_image_adjust_window(panel_rect_min, panel_rect_max);
+
ImGuiWrapper::pop_toolbar_style();
// Drawn last, over everything, via the foreground draw list: the +/- add-remove sign next to the 3D
diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp
index b47c908d88..cd07e86e2e 100644
--- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp
+++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp
@@ -23,6 +23,7 @@
#include "libslic3r/Point.hpp"
#include
#include