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 +#include #include #include #include "slic3r/GUI/Gizmos/GLGizmoBase.hpp" @@ -61,6 +62,12 @@ public: static TextureDisplacementPrepareResult prepare_mesh(const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks, const std::vector &layers, + // `base`'s own UV channel, when it has one + // (ModelVolume::texture_uv). Resampled onto + // the result and reported back in it, and fed + // to the samplers so a ModelUV layer drives + // the refinement through its own layout. + const std::vector &model_uv, const TextureDisplacementPrepareParams ¶ms, const TextureColorSettings &color_settings, const DisplacementProgressFn &progress, @@ -511,6 +518,71 @@ private: std::string m_slider_type_request; int m_slider_type_request_frame = 0; + // ---- The image-adjustment panel ---- + // + // Brightness/contrast/sharpness/blur/hue over one layer's image, shown on the image itself and + // updating as the sliders move. What it produces is not a set of layer parameters: it replaces the + // layer's *image*, so the adjusted pixels feed the colour and the displacement together and a + // brightness change moves the relief too (see TextureAdjustments). + // + // An ordinary floating window rather than a popup, and deliberately so. A popup dims the canvas + // behind it and closes on any click outside itself, which between them make it impossible to watch the + // relief change on the model - which is the whole point of adjusting the image here rather than in an + // image editor. A window leaves the 3D view live, clickable and orbitable while the panel is open. + void render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max); + // Opens the panel for `layer`, decoding its image into m_adjust_source. Sliders never re-decode + // the PNG. + void open_image_adjust(const TextureDisplacementLayer &layer); + // Reads `layer`'s stored image into the panel's source buffers and records which image it came from. + // Called when the panel opens and again whenever the layer's image is swapped under it, which is why + // it leaves the slider values alone. False when the image cannot be decoded. + bool load_image_adjust_source(const TextureDisplacementLayer &layer); + // Resolves the panel: writes the adjusted image into the user's texture library as a new file and + // points `layer` at it, which is the only way an adjustment becomes permanent. Closes the panel. + void commit_image_adjust(TextureDisplacementLayer &layer); + // Closes the panel and reverts the layer's live preview_adjust to identity, so every exit that is not + // a commit - Cancel, the window's close button, the gizmo closing - leaves the layer as it was found. + void close_image_adjust(); + // Collects a finished preview and starts one for the current slider values. One at a time, so a drag + // coalesces into "whatever the sliders last said" instead of a queue of superseded frames. + void update_image_adjust_preview(); + + int m_adjust_slot = -1; // the layer the panel is open for; -1 when closed + TextureAdjustments m_adjust; // what the sliders currently say + // What the layer's live preview_adjust was last set from, so a slider move can be told from a frame in + // which nothing happened and only the former asks the displacement to be recomputed. + TextureAdjustments m_adjust_applied; + + // The layer's image as it was when the panel opened, and a small copy of it. The sliders are applied + // to the small copy for the live preview and to the full-size one exactly once, on commit. Measured on + // a 1024x1024 with every knob engaged, a full-size pass costs 13-19 ms grayscale and 40-60 ms in + // colour (130-190 ms at 2048 in colour) - far too much to repeat per slider frame. The same pass on a + // 384 px copy is 3-4 ms / 9-12 ms, and that is all the preview box can show anyway. + DecodedHeightTexture m_adjust_source; + DecodedHeightTexture m_adjust_preview_source; + // Which image_data the two buffers above were decoded from, so the panel can notice the layer's image + // being swapped from the texture picker while it is open and re-read it. Compared, never dereferenced. + const void *m_adjust_source_image = nullptr; + // And which volume the panel was opened on. Slot numbers are per volume, so without this a change of + // selection would silently rebind the open panel to whichever layer happens to hold the same slot on + // the newly selected object - a different image, under a panel the user opened for this one. + size_t m_adjust_volume_id = 0; + // The preview image on screen, re-uploaded whenever a background pass finishes. + std::unique_ptr m_adjust_preview_tex; + + // The live preview runs on its own thread rather than on the UI thread or on the plater's job + // worker. Not the UI thread because even the small pass is milliseconds and this runs while a slider + // is being dragged; not the job worker because that is a single FIFO shared with Bake and the + // displacement preview (see queue_preview_job()), where a per-frame image filter would sit behind - + // and delay - multi-second mesh work. + std::future m_adjust_preview_job; + TextureAdjustments m_adjust_preview_running; // what the in-flight pass is computing + TextureAdjustments m_adjust_preview_shown; // what m_adjust_preview_tex holds + bool m_adjust_preview_valid = false; + // Drops the GPU copies of the layer's image so the next frame rebuilds them from the decode, which + // is where the live adjustment is applied. Called whenever the panel moves or reverts it. + void invalidate_adjust_textures(); + // "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active // layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane // (a "pan" - drag anywhere on it for free 2D movement), plus two arrows along the patch's @@ -529,8 +601,8 @@ private: // obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's // foreground draw list, so it must be called from inside the gizmo's ImGui frame. void render_paint_cursor_hint(); - // Mesh-local tangent-plane basis at m_adjust_anchor_normal, matching project_planar()'s - // dominant-axis convention so dragging on-canvas maps consistently onto offset. + // Mesh-local tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging + // on-canvas maps consistently onto offset whichever face the anchor is on. void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const; // The plane a drag is measured against: the paint patch's anchor, lifted clear of the surface. @@ -931,18 +1003,31 @@ private: // Merges chart `b`'s join group into chart `a`'s (materialising `groups` to `chart_count` first). static void join_island_groups(std::vector &groups, int a, int b, int chart_count); // Final per-vertex texture uv for the projections the shader can't reconstruct itself - LSCM (an - // unwrap) and ViewProjected (a projector plane the shader doesn't know). One entry per patch/base - // vertex, already through apply_uv_transform(). Empty for Triplanar/Cylindrical/Spherical, which - // the shader projects on its own. Shared by the shaded preview and the UV-check overlay. + // unwrap), ModelUV (the model's own channel) and ViewProjected (a projector plane the shader + // doesn't know). One entry per patch/base vertex, already through apply_uv_transform(). Empty for + // Triplanar/Cylindrical/Spherical, which the shader projects on its own. Shared by the shaded + // preview and the UV-check overlay. + // + // `patch_source` maps each patch triangle back to its base-mesh triangle + // (TriangleSelector::get_facets_strict()'s out_source). Only ModelUV needs it - its channel is per + // base triangle - and without it a ModelUV layer reports no uvs and the preview falls back. std::vector compute_layer_vertex_uvs(const indexed_triangle_set &patch, - const TextureDisplacementLayer &layer) const; + const TextureDisplacementLayer &layer, + const std::vector *patch_source = nullptr) const; // The same, but three UVs per patch triangle (corner 0..2 of triangle i at 3i..3i+2). This is what // the flat, unshared-vertex preview meshes actually want: under LSCM a seam vertex has a different // UV in each island it borders, so collapsing to one per vertex handed a triangle at an unjoined // seam its neighbour's placement - one visibly skewed triangle per face. Every other projection is // single-valued per point, so there a corner's UV is just its vertex's. std::vector compute_layer_corner_uvs(const indexed_triangle_set &patch, - const TextureDisplacementLayer &layer) const; + const TextureDisplacementLayer &layer, + const std::vector *patch_source = nullptr) const; + // The model's own channel over `local_patch`, already through apply_uv_transform() - three per + // patch triangle. Empty when the volume has no channel, or when `patch_source` is missing. Shared + // by the two functions above so the per-vertex and per-corner forms can never disagree. + std::vector model_uv_corners(const indexed_triangle_set &local_patch, + const TextureDisplacementLayer &layer, + const std::vector *patch_source) const; // The in-shader projection for `layer` (0 Triplanar, 1 Cylindrical, 2 Spherical) plus, for the two // wrapping ones, the patch centroid and cylinder axis they wrap around - in the texture frame the // shaders project in. Taken from the bake's own texture_displacement_patch_frame(), so a preview @@ -991,6 +1076,7 @@ private: enum class UVBackground { None, Height, Checker }; UVBackground m_uv_editor_bg = UVBackground::None; float m_uv_editor_bg_smoothing = -1.f; // smoothing the height backdrop was uploaded at + TextureAdjustments m_uv_editor_bg_adjust; // and the live image adjustment it was uploaded at const void *m_uv_editor_bg_image = nullptr; // the layer image it was uploaded from // Per-chart distortion heatmap colour for the UV pane (#7/#14), computed once when the unwrap is // re-solved (relative stretch doesn't change when islands are merely moved), fed to the canvas only diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index e769800749..72363e4393 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -8,6 +8,7 @@ #include "libslic3r/TextureDisplacement.hpp" #include #include +#include #include "slic3r/GUI/Jobs/Worker.hpp" #include @@ -26,6 +27,7 @@ #include "slic3r/Utils/UndoRedo.hpp" #include "libslic3r/ObjectID.hpp" #include "libslic3r/TriangleMesh.hpp" +#include namespace Slic3r::GUI { @@ -74,7 +76,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) } return true; }, - color, m_input.volume_to_world, nullptr, &m_stats)); + color, m_input.volume_to_world, nullptr, &m_stats, + m_input.model_uv.empty() ? nullptr : &m_input.model_uv)); // Always finish at 100: this is what closes the notification. Reported even on cancel, where // build_texture_displacement() returns an empty mesh and finalize() commits nothing. @@ -126,16 +129,71 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept { mix.a_percent(), 100 - mix.a_percent() }); }); + // Whether the bake replaced the triangles or only moved the vertices it was given. The classic + // path is topology-preserving by contract, so every per-triangle annotation on the volume still + // means what it did; the default pipeline refines, cuts and simplifies, so none of them do. + // Both are checked: the flag is what the bake promised, the count is what it delivered. Only + // the triangles matter - the annotations are per facet and reference no vertex index - so a + // vertex count that changed on its own (the bake compacts unreferenced vertices away) is not a + // reason to remap anything. + const bool topology_changed = m_input.options.pipeline_v2 || + m_result.its.indices.size() != m_input.base_mesh.indices.size(); + + // Carry the volume's existing paint - the model's own colours above all - across that rebuild. + // Without it a bake left the mmu_segmentation_facets bitstream pointing at a triangle set that + // no longer existed, which is what scrambled the colours of an imported coloured model even + // with colour switched off on every layer and nothing in the bake that was meant to touch them. + // + // transfer_facet_paint() rather than ModelVolume::restore_painting(): the latter's remap wants + // a target triangle overlapping the source paint to within 0.01 mm, which the baked surface is + // nowhere near - it has moved by the relief depth. Nothing to do when the topology is intact: + // triangle i is still triangle i, so the paint is already exactly right and carrying it at all + // could only lose detail. + std::array before; + if (topology_changed) { + before[0] = volume->supported_facets.get_data(); + before[1] = volume->seam_facets.get_data(); + before[2] = volume->mmu_segmentation_facets.get_data(); + before[3] = volume->fuzzy_skin_facets.get_data(); + } + volume->set_mesh(std::move(m_result)); volume->set_new_unique_id(); volume->calculate_convex_hull(); + if (topology_changed) { + const std::array target{ &volume->supported_facets, &volume->seam_facets, + &volume->mmu_segmentation_facets, + &volume->fuzzy_skin_facets }; + for (size_t ch = 0; ch < target.size(); ++ch) { + const std::vector state = + transfer_facet_paint(m_input.base_mesh, before[ch], volume->mesh().its); + // Nothing was painted in this channel, or there is nothing to paint it onto: either + // way the annotation must not keep describing the mesh that is gone. + if (state.empty()) { + target[ch]->reset(); + continue; + } + TriangleSelector selector(volume->mesh()); + bool any = false; + for (size_t i = 0; i < state.size(); ++i) + if (state[i] != 0) { + selector.set_facet(int(i), EnforcerBlockerType(state[i])); + any = true; + } + if (any) + target[ch]->set(selector); + else + target[ch]->reset(); + } + } + // Colour lands in mmu_segmentation_facets, merged *over* whatever is already painted there // rather than replacing it: a triangle the texture does not colour keeps its existing filament, // and one the user never painted at all stays at NONE, which already means "the volume's own // filament". That is what confines the effect to the painted area without having to invent a - // colour for everything outside it. Safe to index straight onto the new mesh - the bake is - // topology-preserving, so triangle i is still triangle i. + // colour for everything outside it. What was already painted is whatever the transfer above + // left in the annotation, so this bake's colour lands on top of it rather than instead of it. if (!m_triangle_color.empty() && m_triangle_color.size() == volume->mesh().its.indices.size()) { TriangleSelector selector(volume->mesh()); const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data(); @@ -159,7 +217,6 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept // pipeline, which rebuilds and then simplifies the mesh. A mask left behind against the old // topology is exactly what makes the gizmo reload an empty selector over a non-empty mask and // then erase it on the next flush - see GLGizmoTextureDisplacement::update_from_model_object(). - const bool topology_changed = m_input.base_mesh.indices.size() != volume->mesh().its.indices.size(); for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { const auto it = std::find_if(m_input.layers.begin(), m_input.layers.end(), [slot](const TextureDisplacementLayer &l) { return l.slot == slot; }); @@ -167,6 +224,14 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept volume->texture_displacement_facet(slot).reset(); } + // The model's own UV channel. Resampled onto a rebuilt mesh from the surface the bake started + // from: a displaced vertex came from its foot point on that surface, which is the position its + // uv belongs to - so no distance limit, the relief depth is exactly how far it has moved. + if (!m_input.model_uv.empty()) + volume->texture_uv = topology_changed ? transfer_uv_channel(m_input.base_mesh, m_input.model_uv, + volume->mesh().its) : + m_input.model_uv; + ModelObject *object = volume->get_object(); if (object == nullptr) return; @@ -219,6 +284,8 @@ void queue_texture_displacement_bake(const ModelVolume &volume, const TextureCol input.take_snapshot = take_snapshot; input.volume_id = volume.id(); input.base_mesh = volume.mesh().its; + if (volume.has_texture_uv()) + input.model_uv = volume.texture_uv; input.layers = volume.texture_displacement_layers; input.options = volume.texture_displacement_options; input.volume_to_world = texture_displacement_volume_to_world(volume); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp index 40d7ab7b2b..5e1a586736 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp @@ -34,6 +34,9 @@ struct TextureDisplacementBakeInput // writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU // paint gizmo writes - alongside the displaced geometry. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), empty when it has none: what a ModelUV + // layer maps through, and what the commit puts back on the baked mesh. + std::vector model_uv; // Whether this job pushes its own undo step when it commits. False when the caller has already // taken one that is meant to cover the displacement as well - Standard mode's Bake, which remeshes // and subdivides first and has to undo as a single action. diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp index 12701ef3c9..eb4eec036f 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp @@ -17,6 +17,7 @@ #include "slic3r/GUI/Jobs/Worker.hpp" #include #include "libslic3r/ObjectID.hpp" +#include "libslic3r/Point.hpp" #include "libslic3r/TriangleMesh.hpp" namespace Slic3r::GUI { @@ -52,13 +53,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl) indexed_triangle_set mesh = m_input.base_mesh; TextureDisplacementFacetsData masks = m_input.facets_data; + std::vector uv = m_input.model_uv; // The default pipeline can only move vertices the mesh already has, so its recipe starts with the // preparation - which is where "first it remeshes, then it refines" actually happens. The // experimental pipeline refines as part of the bake and has nothing to prepare. if (m_input.run_prepare && !m_input.options.pipeline_v2) { const TextureDisplacementPrepareResult prepared = - GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params, + GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, uv, m_input.prepare_params, m_input.color, // Preparation is roughly half the run; the bake // takes the progress bar from there. @@ -77,12 +79,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl) if (!prepared.mesh.indices.empty()) { mesh = prepared.mesh; masks = prepared.masks; + uv = prepared.uv; // resampled onto the prepared mesh, as the prepare commit stores it } } build_texture_displacement(mesh, m_input.layers, masks, m_input.options, [&report](int pct) { return report(50 + pct / 2); }, - /* color */ nullptr, m_input.volume_to_world, &recorder); + /* color */ nullptr, m_input.volume_to_world, &recorder, /* stats */ nullptr, + uv.empty() ? nullptr : &uv); m_stages = recorder.take(); ctl.update_status(100, status); // always finish at 100: this is what closes the notification @@ -115,6 +119,8 @@ void queue_texture_displacement_debug(const ModelVolume &volume, const TextureCo input.check_topology = check_topology; input.color = color; input.volume_to_world = texture_displacement_volume_to_world(volume); + if (volume.has_texture_uv()) + input.model_uv = volume.texture_uv; for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data(); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp index f4f9f88f4e..981a17773f 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.hpp @@ -47,6 +47,9 @@ struct TextureDisplacementDebugInput // Only the quantizer is used, and only by the refinement's colour criterion - the debug run // produces no colour of its own. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), so the recipe captured here is the one a + // ModelUV layer actually bakes through. Empty when the volume has none. + std::vector model_uv; // The edge scan behind the open / non-manifold counts is a sort over every half-edge, which on a // multi-million triangle stage costs more than the stage did. On by default because a stage that diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp index 8a956af98d..6c582d3746 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp @@ -46,7 +46,7 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl) // idle loop. int last_reported = 1; m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers, - m_input.params, m_input.color, + m_input.model_uv, m_input.params, m_input.color, [&ctl, &status, &last_reported](int percent) { if (ctl.was_canceled()) return false; @@ -106,6 +106,10 @@ void TextureDisplacementPrepareJob::finalize(bool canceled, std::exception_ptr & volume->restore_painting(saved_painting); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) volume->texture_displacement_facet(i).set_data(std::move(m_result.masks[size_t(i)])); + // The model's own UV channel, resampled onto the new mesh by the run. Also put back after + // restore_painting(), which resets it along with the extra facets for the same reason: it is + // indexed by triangle and the triangles have changed. + volume->texture_uv = std::move(m_result.uv); if (ObjectList *obj_list = wxGetApp().obj_list()) { const ModelObjectPtrs &objs = plater->model().objects; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp index 5fc173975f..a07bf63ae9 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp @@ -9,6 +9,7 @@ #include "Job.hpp" #include "libslic3r/ObjectID.hpp" +#include "libslic3r/Point.hpp" #include "libslic3r/TextureDisplacement.hpp" namespace Slic3r { @@ -47,6 +48,10 @@ struct TextureDisplacementPrepareInput // skips the colour criterion entirely. Only the *quantizer* is used here: refinement follows // perceived colour, never the interleaving that realises a mix. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), empty when it has none. prepare_mesh() + // resamples it onto the mesh it produces, and the commit puts it back - like the masks, it is + // indexed by triangle and so cannot survive the stages on its own. + std::vector model_uv; // The undo step the commit opens. Standard mode's Bake names it after the bake, because the // displacement job that follows commits into this same step rather than pushing its own. std::string snapshot_name; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp index 4243cdd9d3..32b88aa951 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -48,7 +48,8 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl) (!m_current_generation || m_current_generation->load() == m_generation); }, - color, m_input.volume_to_world); + color, m_input.volume_to_world, nullptr, nullptr, + m_input.model_uv.empty() ? nullptr : &m_input.model_uv); } void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr) diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp index 07fcca8664..7f7941e828 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp @@ -29,6 +29,9 @@ struct TextureDisplacementPreviewInput // Empty unless a layer is colouring, in which case the preview reports the palette entry per // triangle alongside the mesh, so the Normal view shows the colours the bake will produce. TextureColorSettings color; + // The volume's own UV channel (ModelVolume::texture_uv), so a ModelUV layer previews through the + // same layout it will bake through. Empty when the volume has none. + std::vector model_uv; }; // A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per diff --git a/src/slic3r/GUI/TextureLibrary.cpp b/src/slic3r/GUI/TextureLibrary.cpp index e3f73258df..69301b42d1 100644 --- a/src/slic3r/GUI/TextureLibrary.cpp +++ b/src/slic3r/GUI/TextureLibrary.cpp @@ -27,6 +27,7 @@ #include #include "libslic3r/PNGReadWrite.hpp" +#include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/AppConfig.hpp" @@ -158,6 +159,28 @@ bool encode_png_bytes(const wxImage &image, std::vector &out, std : encode_gray_png_bytes(image, out, error); } +// A wxImage over decoded pixels, so the encoders above can be reached from an in-memory image as well as +// from a file. Grayscale is expanded to the three equal channels wxImage always stores, which is what +// image_has_color() then reads as "no colour" - so a grey image still encodes as a grayscale PNG. +// +// `scratch` holds the pixel buffer: the wxImage is constructed over it without taking ownership, so it +// has to outlive every use of the returned image. +wxImage image_from_decoded(const DecodedHeightTexture &image, std::vector &scratch) +{ + if (image.empty()) + return wxImage(); + + const size_t n = size_t(image.width) * size_t(image.height); + scratch.resize(n * 3); + if (image.has_color()) + scratch.assign(image.rgb.begin(), image.rgb.end()); + else + for (size_t i = 0; i < n; ++i) + scratch[i * 3] = scratch[i * 3 + 1] = scratch[i * 3 + 2] = image.pixels[i]; + + return wxImage(image.width, image.height, scratch.data(), /* static_data */ true); +} + std::vector read_file_bytes(const std::string &path) { std::ifstream ifs(path, std::ios::binary); @@ -186,6 +209,42 @@ bool is_supported_height_map(const std::vector &bytes) std::vector g_library; bool g_library_scanned = false; +// Writes `bytes` into the user's own texture folder as `stem`.png, uniquified so an existing texture - +// shipped-name collision, or a second edit of the same source - is never overwritten. Returns the new +// file's library entry. Shared by import_texture_to_library() and save_texture_to_library(), which +// differ only in where their bytes came from. +std::optional write_user_texture(const std::vector &bytes, const std::string &stem, + std::string &error) +{ + const std::string user_dir = user_texture_dir(); + if (user_dir.empty()) { + error = _u8L("Could not create the folder for imported textures."); + return std::nullopt; + } + + boost::filesystem::path dest = boost::filesystem::path(user_dir) / (stem + ".png"); + for (int i = 2; boost::filesystem::exists(dest); ++i) + dest = boost::filesystem::path(user_dir) / (stem + " (" + std::to_string(i) + ").png"); + + { + boost::nowide::ofstream ofs(dest.string(), std::ios::binary); + ofs.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); + if (!ofs.good()) { + error = _u8L("Failed to save the imported texture."); + return std::nullopt; + } + } + + texture_library(true); // pick the new file up + const std::string dest_str = dest.string(); + for (const TextureLibraryEntry &e : g_library) + if (e.path == dest_str) + return e; + + error = _u8L("Failed to save the imported texture."); + return std::nullopt; +} + // The user's own textures, most recently used first, kept in the app config. File names are stored rather // than paths so the order survives a moved data directory; '/' separates them because no platform allows // it inside a file name. Bounded so a long-lived config does not grow a line without end. @@ -317,12 +376,6 @@ bool remove_user_texture(const std::string &path, std::string &error) std::optional import_texture_to_library(const std::string &source_path, std::string &error) { - const std::string user_dir = user_texture_dir(); - if (user_dir.empty()) { - error = _u8L("Could not create the folder for imported textures."); - return std::nullopt; - } - wxImage image; if (!image.LoadFile(from_u8(source_path)) || !image.IsOk()) { error = _u8L("Could not load the selected image."); @@ -333,30 +386,7 @@ std::optional import_texture_to_library(const std::string & if (!encode_png_bytes(image, bytes, error)) return std::nullopt; - // Never overwrite an existing texture (the user's or, if they picked the same name twice, their - // own earlier import) - uniquify instead. - const std::string stem = boost::filesystem::path(source_path).stem().string(); - boost::filesystem::path dest = boost::filesystem::path(user_dir) / (stem + ".png"); - for (int i = 2; boost::filesystem::exists(dest); ++i) - dest = boost::filesystem::path(user_dir) / (stem + " (" + std::to_string(i) + ").png"); - - { - boost::nowide::ofstream ofs(dest.string(), std::ios::binary); - ofs.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); - if (!ofs.good()) { - error = _u8L("Failed to save the imported texture."); - return std::nullopt; - } - } - - texture_library(true); // pick the new file up - const std::string dest_str = dest.string(); - for (const TextureLibraryEntry &e : g_library) - if (e.path == dest_str) - return e; - - error = _u8L("Failed to save the imported texture."); - return std::nullopt; + return write_user_texture(bytes, boost::filesystem::path(source_path).stem().string(), error); } std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error) @@ -382,4 +412,21 @@ std::shared_ptr> load_texture_image_data(const std::s return std::make_shared>(std::move(converted)); } +std::optional save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem, + std::string &error) +{ + std::vector scratch; + const wxImage wx = image_from_decoded(image, scratch); + if (!wx.IsOk()) { + error = _u8L("The selected image is empty."); + return std::nullopt; + } + + std::vector bytes; + if (!encode_png_bytes(wx, bytes, error)) + return std::nullopt; + + return write_user_texture(bytes, stem.empty() ? std::string("texture") : stem, error); +} + } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureLibrary.hpp b/src/slic3r/GUI/TextureLibrary.hpp index 65b35bcea8..38ad277a84 100644 --- a/src/slic3r/GUI/TextureLibrary.hpp +++ b/src/slic3r/GUI/TextureLibrary.hpp @@ -6,6 +6,8 @@ #include #include +#include "libslic3r/TextureDisplacement.hpp" + namespace Slic3r::GUI { // One selectable height-map texture in the texture-displacement gizmo's texture picker. @@ -57,6 +59,14 @@ std::optional import_texture_to_library(const std::string & // Returns nullptr with `error` set if the file cannot be read or decoded at all. std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error); +// Writes `image` into user_texture_dir() as a new PNG named after `stem` (uniquified if taken, so an +// existing texture is never overwritten) and returns its library entry. The in-memory counterpart of +// import_texture_to_library(), for an image that was edited rather than read off disk. +// +// Returns nullopt with `error` set on failure. +std::optional save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem, + std::string &error); + } // namespace Slic3r::GUI #endif // slic3r_TextureLibrary_hpp_ diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp index a4a9901822..87f629816f 100644 --- a/src/slic3r/GUI/UVEditorCanvas.cpp +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -36,6 +36,7 @@ #include "3DScene.hpp" #include "BitmapCache.hpp" +#include "GLCanvas3D.hpp" #include "GLShader.hpp" #include "GUI_App.hpp" #include "I18N.hpp" @@ -1324,8 +1325,13 @@ void UVEditorCanvas::on_size(wxSizeEvent &evt) // Update() flushes it immediately - still through the normal paint path (unlike calling // render() directly), which matters because this canvas shares the app's one GL context with // the 3D view and must not hijack it outside its own paint. + // Not while a 3D canvas frame is in progress, though: wxGTK delivers this size event synchronously + // when the texture displacement gizmo relays out the pane from its ImGui panel, i.e. from inside + // that frame, and flushing the paint there would take the shared context away from it. The Refresh() + // then runs the paint once the frame is over. Refresh(); - Update(); + if (!GLCanvas3D::is_frame_in_progress()) + Update(); } void UVEditorCanvas::on_leave(wxMouseEvent &evt) @@ -1346,6 +1352,16 @@ void UVEditorCanvas::render() { if (m_context == nullptr || !IsShownOnScreen()) return; + // Painted from inside a 3D canvas frame - a modal dialog the gizmo opened from its ImGui panel runs + // this paint, as does any other synchronous event raised there. Making this surface current now + // would take the shared context away from that canvas mid-frame: its remaining draws would land + // here and, on Mesa/X11, its SwapBuffers() would be a fatal BadMatch. Skip, but ask for another + // paint so the skipped one is not simply lost. The pane stays stale for as long as a nested modal + // loop keeps a frame on the stack, which is behind the dialog anyway. + if (GLCanvas3D::is_frame_in_progress()) { + Refresh(); + return; + } // wxGLCanvas::SetCurrent() genuinely fails (returns false) on a canvas that isn't shown on // screen, and can also fail on a pixel-format mismatch with the context - and every GL call diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index f71c839c42..4c3e74d705 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -36,10 +37,12 @@ #include #include "libslic3r/AABBMesh.hpp" +#include "libslic3r/AABBTreeIndirect.hpp" #include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/TextureBake/TextureBakeDecimate.hpp" #include "libslic3r/TextureBake/TextureBakeFlip.hpp" #include "libslic3r/TextureBake/TextureBakeMesh.hpp" +#include "libslic3r/TextureBake/TextureBakeRepair.hpp" #include "libslic3r/TriangleMesh.hpp" #include "libslic3r/TriangleSelector.hpp" #include "libslic3r/PNGReadWrite.hpp" @@ -1301,6 +1304,294 @@ TEST_CASE("TextureDisplacement: the step cutter only cuts inside the region", "[ } } +// ---- Image adjustments (the gizmo's image-adjustment panel) ---------------------------------------- + +// A grayscale ramp 0..255 across the width, so every input level is present exactly once per row and a +// tonal curve can be read straight off the output. +static DecodedHeightTexture make_gray_ramp(int w = 256, int h = 4) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + t.pixels[size_t(y) * size_t(w) + size_t(x)] = uint8_t(x * 255 / std::max(1, w - 1)); + return t; +} + +// A one-texel checkerboard of 0 and 255 - the highest spatial frequency the grid can hold, which is what +// a blur has to remove and a downscale has to average rather than drop. +static DecodedHeightTexture make_gray_checkerboard(int w = 16, int h = 16) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + t.pixels[size_t(y) * size_t(w) + size_t(x)] = ((x + y) % 2 == 0) ? 0 : 255; + return t; +} + +static DecodedHeightTexture make_rgb_flat(uint8_t r, uint8_t g, uint8_t b, int w = 8, int h = 8) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + t.rgb.resize(size_t(w) * size_t(h) * 3); + for (size_t i = 0; i < size_t(w) * size_t(h); ++i) { + t.rgb[i * 3] = r; + t.rgb[i * 3 + 1] = g; + t.rgb[i * 3 + 2] = b; + t.pixels[i] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b)); + } + return t; +} + +TEST_CASE("an adjustment that moves no slider returns the image unchanged", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + const DecodedHeightTexture out = apply_texture_adjustments(src, TextureAdjustments{}); + CHECK(out.width == src.width); + CHECK(out.height == src.height); + CHECK(out.pixels == src.pixels); + CHECK(TextureAdjustments{}.identity()); +} + +TEST_CASE("brightness reaches pure white and pure black only at the ends of its range", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + + TextureAdjustments up; + up.brightness = 1.f; + const DecodedHeightTexture white = apply_texture_adjustments(src, up); + CHECK(std::all_of(white.pixels.begin(), white.pixels.end(), [](uint8_t v) { return v == 255; })); + + TextureAdjustments down; + down.brightness = -1.f; + const DecodedHeightTexture black = apply_texture_adjustments(src, down); + CHECK(std::all_of(black.pixels.begin(), black.pixels.end(), [](uint8_t v) { return v == 0; })); +} + +TEST_CASE("brightness lifts the image without flattening it into a clipped plateau", "[TextureDisplacement]") +{ + // The reason brightness is a lift toward white rather than an additive offset: an offset would drive + // everything above mid-grey to a flat 255, and since this image is also the height map that region + // would bake as flat relief. Half a stop up must leave almost nothing at pure white. + const DecodedHeightTexture src = make_gray_ramp(); + TextureAdjustments adj; + adj.brightness = 0.5f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + + REQUIRE(out.pixels.size() == src.pixels.size()); + const size_t clipped = size_t(std::count(out.pixels.begin(), out.pixels.end(), uint8_t(255))); + CHECK(clipped * 100 < out.pixels.size()); // under 1%; an additive shift would clip half the ramp + + // Every texel got brighter, and the curve stays ordered: brighter in means brighter out. + for (size_t i = 0; i < out.pixels.size(); ++i) + REQUIRE(out.pixels[i] >= src.pixels[i]); + for (int x = 1; x < out.width; ++x) + REQUIRE(out.pixels[size_t(x)] >= out.pixels[size_t(x - 1)]); +} + +TEST_CASE("contrast at its lowest flattens the image to mid-grey and at its highest spreads it apart", + "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + + TextureAdjustments flat; + flat.contrast = -1.f; + const DecodedHeightTexture grey = apply_texture_adjustments(src, flat); + // One value everywhere - the pivot. Which exact level that rounds to is not the point. + CHECK(std::adjacent_find(grey.pixels.begin(), grey.pixels.end(), std::not_equal_to()) == + grey.pixels.end()); + + TextureAdjustments hard; + hard.contrast = 1.f; + const DecodedHeightTexture split = apply_texture_adjustments(src, hard); + // A threshold about the pivot: the dark end of the ramp goes to black, the light end to white. + CHECK(split.pixels[0] == 0); + CHECK(split.pixels[size_t(split.width - 1)] == 255); +} + +TEST_CASE("blur removes the finest detail and sharpness exaggerates an edge", "[TextureDisplacement]") +{ + // Blur: a one-texel checkerboard is the highest frequency the grid holds, so a blur has to collapse it + // toward its own mean. Measured as mean absolute deviation, which needs no assumption about the mean. + const DecodedHeightTexture checker = make_gray_checkerboard(32, 32); + const auto deviation = [](const DecodedHeightTexture &t) { + double mean = 0.; + for (uint8_t v : t.pixels) + mean += double(v); + mean /= double(t.pixels.size()); + double dev = 0.; + for (uint8_t v : t.pixels) + dev += std::abs(double(v) - mean); + return dev / double(t.pixels.size()); + }; + TextureAdjustments blur; + blur.blur = 1.f; + CHECK(deviation(apply_texture_adjustments(checker, blur)) < 0.25 * deviation(checker)); + + // Sharpness: an unsharp mask pushes each side of a step further from the other, so the step + // overshoots - darker than the dark side just inside it, brighter than the bright side just inside. + DecodedHeightTexture step; + step.width = 64; + step.height = 8; + step.pixels.assign(size_t(step.width) * size_t(step.height), 100); + for (int y = 0; y < step.height; ++y) + for (int x = step.width / 2; x < step.width; ++x) + step.pixels[size_t(y) * size_t(step.width) + size_t(x)] = 150; + + TextureAdjustments sharp; + sharp.sharpness = 1.f; + const DecodedHeightTexture sharpened = apply_texture_adjustments(step, sharp); + const size_t row = size_t(step.height / 2) * size_t(step.width); + const size_t mid = size_t(step.width / 2); + CHECK(sharpened.pixels[row + mid - 1] < 100); // undershoot on the dark side of the step + CHECK(sharpened.pixels[row + mid] > 150); // overshoot on the light side +} + +TEST_CASE("a grayscale image stays grayscale and its hue slider does nothing", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + REQUIRE_FALSE(src.has_color()); + + TextureAdjustments hue; + hue.hue_deg = 120.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, hue); + // Not promoted to RGB: a grey image has no colour to offer the layer's colour feature, and storing it + // as RGB would triple the PNG for a row of identical greys. + CHECK_FALSE(out.has_color()); + CHECK(out.rgb.empty()); + CHECK(out.pixels == src.pixels); +} + +TEST_CASE("a colour image's height stays the luminance of its adjusted colour", "[TextureDisplacement]") +{ + // The invariant that keeps the relief and the colour from drifting apart: whatever the adjustment did + // to the channels, the height is recomputed from the channels that came out. + const DecodedHeightTexture src = make_rgb_flat(200, 60, 30); + REQUIRE(src.has_color()); + + TextureAdjustments adj; + adj.brightness = 0.2f; + adj.contrast = 0.3f; + adj.hue_deg = 75.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + REQUIRE(out.has_color()); + + for (size_t i = 0; i < out.pixels.size(); ++i) { + const long expect = std::lround(0.299 * out.rgb[i * 3] + 0.587 * out.rgb[i * 3 + 1] + + 0.114 * out.rgb[i * 3 + 2]); + REQUIRE(long(out.pixels[i]) == expect); + } + // The hue turn actually moved the colour, so the check above is not passing on an unchanged image. + CHECK(out.rgb != src.rgb); +} + +TEST_CASE("a full turn of the hue leaves a colour where it started", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_rgb_flat(200, 60, 30); + TextureAdjustments adj; + adj.hue_deg = 360.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + REQUIRE(out.has_color()); + // Rounding through 8-bit channels, so a level or two of slack rather than exact equality. + for (size_t i = 0; i < out.rgb.size(); ++i) + REQUIRE(std::abs(int(out.rgb[i]) - int(src.rgb[i])) <= 2); +} + +TEST_CASE("the adjustment blur radius ramps from zero and stays under its cost cap", "[TextureDisplacement]") +{ + CHECK_THAT(adjustment_blur_radius(0.f, 512, 512), Catch::Matchers::WithinAbs(0.f, 1e-6f)); + // 5% of the short side, as the Smoothing slider uses. + CHECK_THAT(adjustment_blur_radius(1.f, 512, 512), Catch::Matchers::WithinAbs(25.6f, 1e-3f)); + CHECK_THAT(adjustment_blur_radius(0.5f, 512, 512), Catch::Matchers::WithinAbs(12.8f, 1e-3f)); + // Capped, so a large image at the top of the slider cannot cost without bound. + CHECK_THAT(adjustment_blur_radius(1.f, 8192, 8192), Catch::Matchers::WithinAbs(48.f, 1e-3f)); + // Sized off the short side, not the long one. + CHECK_THAT(adjustment_blur_radius(1.f, 4096, 200), Catch::Matchers::WithinAbs(10.f, 1e-3f)); +} + +TEST_CASE("downscaling a height texture averages the texels it drops and never upscales", + "[TextureDisplacement]") +{ + // Averaging, not decimation: a one-texel checkerboard halved must come out uniformly mid-grey. Picking + // one texel per block would instead come out all black or all white - detail the adjustment never + // produced, shown in the preview. + const DecodedHeightTexture small = downscale_height_texture(make_gray_checkerboard(16, 16), 8); + CHECK(small.width == 8); + CHECK(small.height == 8); + CHECK(std::all_of(small.pixels.begin(), small.pixels.end(), [](uint8_t v) { return v == 127; })); + + // Already within the limit: handed back as it is. + const DecodedHeightTexture ramp = make_gray_ramp(64, 4); + const DecodedHeightTexture same = downscale_height_texture(ramp, 256); + CHECK(same.width == ramp.width); + CHECK(same.height == ramp.height); + CHECK(same.pixels == ramp.pixels); + + // Aspect preserved, and a colour source keeps its colour. + const DecodedHeightTexture wide = downscale_height_texture(make_rgb_flat(200, 60, 30, 400, 100), 100); + CHECK(wide.width == 100); + CHECK(wide.height == 25); + CHECK(wide.has_color()); +} + +TEST_CASE("a layer's live image adjustment reaches its decode", "[TextureDisplacement]") +{ + // This is what makes the image-adjustment panel's sliders move the relief: decode_height_texture() + // applies the layer's preview_adjust, so everything downstream of the decode - the displacement, the + // colour, the thumbnails, the bake - sees the adjusted image without being told about the panel. + TextureDisplacementLayer layer; + layer.image_data = make_checkerboard_png(32, 32); + + const DecodedHeightTexture raw = decode_height_texture(layer); + REQUIRE_FALSE(raw.empty()); + + // An untouched panel, and a closed one, cost nothing and change nothing. + layer.preview_adjust = TextureAdjustments{}; + CHECK(decode_height_texture(layer).pixels == raw.pixels); + + layer.preview_adjust.brightness = 0.5f; + const DecodedHeightTexture bright = decode_height_texture(layer); + CHECK(bright.pixels != raw.pixels); + CHECK(bright.pixels == apply_texture_adjustments(raw, layer.preview_adjust).pixels); + + // Back to identity gives the stored image back, so closing the panel really does revert the layer. + layer.preview_adjust = TextureAdjustments{}; + CHECK(decode_height_texture(layer).pixels == raw.pixels); +} + +TEST_CASE("changing a live adjustment is not served the previous one from cache", "[TextureDisplacement]") +{ + // The decode caches by the image_data pointer, which does not change while a slider moves - so the + // adjustment has to be part of the key or every slider move past the first would redraw the same + // pixels. Same for the smoothed copy, which is computed on top of the adjusted one. + TextureDisplacementLayer layer; + layer.image_data = make_checkerboard_png(32, 32); + const DecodedHeightTexture raw = decode_height_texture(layer); + REQUIRE_FALSE(raw.empty()); + + layer.preview_adjust.brightness = 0.5f; + const std::vector bright = decode_height_texture(layer).pixels; + layer.preview_adjust.brightness = -0.5f; + const std::vector dark = decode_height_texture(layer).pixels; + CHECK(bright != dark); + CHECK(dark == apply_texture_adjustments(raw, layer.preview_adjust).pixels); + + // The same question again with smoothing on, which is cached separately and keyed by both. + layer.smoothing = 0.5f; + layer.preview_adjust.brightness = 0.5f; + const std::vector smooth_bright = decode_height_texture(layer).pixels; + layer.preview_adjust.brightness = -0.5f; + const std::vector smooth_dark = decode_height_texture(layer).pixels; + CHECK(smooth_bright != smooth_dark); +} + // --------------------------------------------------------------------------------------------- // Texture smoothing // --------------------------------------------------------------------------------------------- @@ -1653,6 +1944,321 @@ TEST_CASE("Per-corner LSCM UVs give each triangle its own island's placement", " CHECK(differs); } +// --------------------------------------------------------------------------------------------- +// UV orientation: which way up a texture lands on the model. +// +// v = 0 is the height map's *first* pixel row, i.e. its top (DecodedHeightTexture::sample(), and +// the UV editor negates Y on screen for the same reason). So "the image is the right way up" is the +// statement that the row a point samples gets *lower* as the point moves up the face, and "it is +// not left-right mirrored" is that the column gets higher as the point moves to the right of a +// viewer standing outside the model. Both are measured below by sampling a ramp whose value says +// which row (or column) it came from, which settles the question without having to reason about the +// uv transform at all. + +static constexpr int RAMP_N = 64; // the ramp texture's size, in texels +static constexpr float RAMP_TILING = 40.f; // mm per tile +static constexpr float CUBE_HALF = 5.f; // half extent of the cube the probes walk over +// How far a probe steps off a face centre. Short on purpose: the cylindrical and spherical u is an +// arc length (angle * radius), so a long step also changes the radius, and that term swamps the +// angular one. It is symmetric about the face centre, so at a short step it cancels between the two +// readings a check compares. +static constexpr float RAMP_STEP = 1.f; +// What one step's worth of travel is in ramp rows: 2 * RAMP_STEP mm of separation, RAMP_N texels per +// RAMP_TILING mm. Halved as the margin a check demands, so "flat" cannot pass as either direction. +static constexpr double RAMP_MARGIN = RAMP_N * RAMP_STEP / RAMP_TILING; + +// A height map whose rows (or columns) carry a linear ramp: row r holds 3 + 4r, so the sampled +// height identifies the row. Built directly rather than through a PNG because these tests are about +// the projection, not the decoder - decode_height_texture() has its own tests. +static DecodedHeightTexture ramp_texture(bool by_row) +{ + DecodedHeightTexture t; + t.width = t.height = RAMP_N; + t.pixels.resize(size_t(RAMP_N) * size_t(RAMP_N)); + for (int y = 0; y < RAMP_N; ++y) + for (int x = 0; x < RAMP_N; ++x) + t.pixels[size_t(y) * size_t(RAMP_N) + size_t(x)] = uint8_t(3 + 4 * (by_row ? y : x)); + return t; +} + +// uv 0.5 at the cube's centre, and a tile wide enough that every point the probes visit stays well +// inside the one tile - so no reading is a wrapped-around neighbour of the one it is compared with. +static TextureDisplacementLayer ramp_layer(TextureProjectionMethod method) +{ + TextureDisplacementLayer layer; + layer.projection_method = method; + layer.tiling_scale = RAMP_TILING; + layer.offset = Vec2f(0.5f, 0.5f); + layer.depth_mm = 1.f; + return layer; +} + +// Which row (or column) of `ramp` the layer reads at `p`. Fractional, because a point between two +// texels reads the ramp between them; only the direction it moves in matters here. +static double ramp_index(const DecodedHeightTexture &ramp, const TextureDisplacementLayer &layer, const Vec3f &p, + const Vec3f &n, const Vec3f ¢er = Vec3f(Vec3f::Zero()), + const Vec3f &axis = Vec3f(Vec3f::UnitZ())) +{ + return (double(sample_layer_height(ramp, layer, p, n, center, axis, nullptr)) * 255.0 - 3.0) / 4.0; +} + +// How a viewer standing outside the model sees a face: their right, and their up. Up is world +Z on +// the four upright faces; on the top and bottom, where +Z is edge on, it is world +Y. +static void outside_viewer_frame(const Vec3f &n, Vec3f &right, Vec3f &up) +{ + up = (std::abs(n.z()) < 0.9f) ? Vec3f(0.f, 0.f, 1.f) : Vec3f(0.f, 1.f, 0.f); + right = Vec3f((-n).cross(up).normalized()); +} + +static const std::array, 6> CUBE_FACES = { { + { "+X", Vec3f(1.f, 0.f, 0.f) }, + { "-X", Vec3f(-1.f, 0.f, 0.f) }, + { "+Y", Vec3f(0.f, 1.f, 0.f) }, + { "-Y", Vec3f(0.f, -1.f, 0.f) }, + { "+Z", Vec3f(0.f, 0.f, 1.f) }, + { "-Z", Vec3f(0.f, 0.f, -1.f) }, +} }; + +TEST_CASE("the box projection lands a texture upright and unmirrored on all six faces of a cube", + "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + const DecodedHeightTexture cols = ramp_texture(false); + const TextureDisplacementLayer layer = ramp_layer(TextureProjectionMethod::Triplanar); + + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + + // Upright: the row read near the top of the face is nearer the image's first row. + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top of the face " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + + // Unmirrored: the column read to the viewer's right is further into the image. Without a + // per-side sign this fails on exactly the three faces facing away - -X, +Y and -Z - which + // is the mirroring a bare v flip leaves behind. + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left of the face " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("every projection method lands a texture upright on an upright face", "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + // ViewProjected is one flat projection from the captured camera rather than a per-face map, so its + // "up" is the projector's own (view_project_up), which the default capture puts at +Y. + const auto method = GENERATE(TextureProjectionMethod::Triplanar, TextureProjectionMethod::Cylindrical, + TextureProjectionMethod::Spherical, TextureProjectionMethod::ViewProjected); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // Only the four upright faces: on the top and bottom a cylinder's v is constant (they are its + // caps) and a sphere's is at an extremum (they are its poles), so neither says anything about + // which way up the image is. + if (std::abs(n.z()) > 0.9f) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + const Vec3f up = (method == TextureProjectionMethod::ViewProjected) ? layer.view_project_up : + Vec3f(0.f, 0.f, 1.f); + const Vec3f centre = n * CUBE_HALF; + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + } + } +} + +TEST_CASE("the cylindrical and spherical wraps run left to right as seen from outside", "[TextureDisplacement]") +{ + const DecodedHeightTexture cols = ramp_texture(false); + const auto method = GENERATE(TextureProjectionMethod::Cylindrical, TextureProjectionMethod::Spherical); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // The caps and poles are out, as above - u is undefined on the axis. So is whichever face the + // atan2 branch cut runs through, where u jumps from +pi to -pi: that is a real seam in both + // projections, and a reading taken across it compares two opposite sides of the model. + if (std::abs(n.z()) > 0.9f) + continue; + const bool on_the_cut = (method == TextureProjectionMethod::Cylindrical) ? (n.y() < -0.9f) : (n.x() < -0.9f); + if (on_the_cut) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("an unwrapped cube's flat charts are laid out the right way up", "[TextureDisplacement]") +{ + indexed_triangle_set cube = its_make_cube(2. * CUBE_HALF, 2. * CUBE_HALF, 2. * CUBE_HALF); + for (Vec3f &v : cube.vertices) + v -= Vec3f(CUBE_HALF, CUBE_HALF, CUBE_HALF); + + const PatchUnwrap unwrap = compute_patch_unwrap(cube, LSCM_DEFAULT_SEAM_ANGLE_DEG); + REQUIRE_FALSE(unwrap.empty()); + REQUIRE(unwrap.chart_count == 6); // one flat chart per face, cut at the 90-degree edges + + // Each chart carries one face. The four upright ones span world Z, and their v has to grow + // downwards like every other projection's - whatever the packing then did with the island. + int upright_charts = 0; + for (int c = 0; c < unwrap.chart_count; ++c) { + int top = -1, bottom = -1; + for (size_t i = 0; i < unwrap.uvs.size(); ++i) { + if (unwrap.vertex_chart[i] != c) + continue; + const float z = cube.vertices[size_t(unwrap.source_vertex[i])].z(); + if (top < 0 || z > cube.vertices[size_t(unwrap.source_vertex[size_t(top)])].z()) + top = int(i); + if (bottom < 0 || z < cube.vertices[size_t(unwrap.source_vertex[size_t(bottom)])].z()) + bottom = int(i); + } + REQUIRE(top >= 0); + REQUIRE(bottom >= 0); + const float v_top = unwrap.uvs[size_t(top)].y(), v_bottom = unwrap.uvs[size_t(bottom)].y(); + if (std::abs(v_top - v_bottom) < 1e-3f) + continue; // a cap: the whole chart is at one height, so it has no up + ++upright_charts; + DYNAMIC_SECTION("chart " << c) + { + INFO("v at the chart's topmost vertex " << v_top << ", at its bottommost " << v_bottom); + CHECK(v_bottom - v_top > 1.f); + } + } + CHECK(upright_charts == 4); // the four sides; the top and bottom faces are the caps skipped above +} + +TEST_CASE("project_planar agrees with the box projection's own axes", "[TextureDisplacement]") +{ + // The two express the same table in different shapes - one a pair of world axes for the GUI, the + // other the closed form the bake's hot path uses - so they are checked against each other here + // rather than left to drift apart. + const Vec3f p(2.f, -3.f, 7.f); + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f u_axis, v_axis; + box_projection_axes(n, u_axis, v_axis); + const Vec2f planar = project_planar(p, n); + CHECK_THAT(planar.x(), WithinAbs(p.dot(u_axis), 1e-5f)); + CHECK_THAT(planar.y(), WithinAbs(p.dot(v_axis), 1e-5f)); + // Right-handed for a viewer outside the model: u to their right, v down, so u x v points + // into the surface. Getting this wrong is exactly what mirrors a face. + CHECK_THAT(u_axis.cross(v_axis).dot(n), WithinAbs(-1.f, 1e-5f)); + } + } +} + +// An image with a different value in each quadrant, so one bake says both which way up it landed +// and whether it came out mirrored. Distinct enough that a wrong quadrant cannot be mistaken for the +// right one: top-left 255, top-right 170, bottom-left 85, bottom-right 0. +static std::shared_ptr> make_quadrant_marker_png(size_t w = 32, size_t h = 32) +{ + std::vector pixels(w * h); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) + pixels[y * w + x] = (y < h / 2) ? ((x < w / 2) ? 255 : 170) : ((x < w / 2) ? 85 : 0); + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + +TEST_CASE("baking an asymmetric image puts the top of the image at the top of the model", "[TextureDisplacement]") +{ + // An upright wall in the plane y = 0, facing -Y, tessellated so that each of the image's four + // quadrants gets a vertex at its centre. A flat sheet rather than a cube because every vertex + // normal is then exactly the face normal, which is what makes the expected depth below an exact + // number instead of a tri-planar blend of three of them. + constexpr int STEPS = 4; // cells per side, so 5x5 vertices + constexpr float SPAN = 20.f; // mm, the wall's width and height + indexed_triangle_set wall; + for (int j = 0; j <= STEPS; ++j) + for (int i = 0; i <= STEPS; ++i) + wall.vertices.emplace_back(-SPAN / 2.f + SPAN * float(i) / float(STEPS), 0.f, + SPAN * float(j) / float(STEPS)); + const auto at = [](int i, int j) { return j * (STEPS + 1) + i; }; + for (int j = 0; j < STEPS; ++j) + for (int i = 0; i < STEPS; ++i) { + // Wound so that (+x) x (+z) = -y: the wall faces -Y, away from the origin. + wall.indices.emplace_back(at(i, j), at(i + 1, j), at(i + 1, j + 1)); + wall.indices.emplace_back(at(i, j), at(i + 1, j + 1), at(i, j + 1)); + } + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(wall); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.projection_method = TextureProjectionMethod::Triplanar; + layer.image_data = make_quadrant_marker_png(); + layer.depth_mm = 2.f; + // The wall is 20 mm across and one tile is 40 mm, so it covers the middle half of the image in + // both axes: the quadrant boundaries land on x = 0 and z = 10, and the probed vertices sit a + // whole number of texels away from both. + layer.tiling_scale = 40.f; + layer.offset = Vec2f(0.5f, 0.75f); + // A single decal rather than a repeat, so that getting the orientation wrong cannot be masked by + // the wrap bringing the flipped coordinate back into the image at a plausible-looking row. With + // tiling off, uv stays inside [0.25, 0.75] in both axes only while v runs the right way. + layer.tile_enabled = false; + + const indexed_triangle_set result = build_texture_displacement(wall, { layer }, facets, classic_options()); + REQUIRE(result.vertices.size() == wall.vertices.size()); + + // Seen from outside this face the viewer's right is +X and their up is +Z, so the image's + // top-left quadrant has to land at low x and high z. Each entry: the vertex, and the image value + // whose quadrant it must have read. + const std::array, 4> expected = { { + { "top-left (255)", 1, 3, 255 }, + { "top-right (170)", 3, 3, 170 }, + { "bottom-left (85)", 1, 1, 85 }, + { "bottom-right (0)", 3, 1, 0 }, + } }; + + for (const auto &[name, i, j, value] : expected) { + DYNAMIC_SECTION("quadrant " << name) + { + const size_t vi = size_t(at(i, j)); + // The whole displacement is along -Y, so this is the relief's height at that vertex. + const float raised = wall.vertices[vi].y() - result.vertices[vi].y(); + INFO("vertex at " << wall.vertices[vi].x() << ", " << wall.vertices[vi].z()); + CHECK_THAT(raised, WithinAbs(layer.depth_mm * float(value) / 255.f, 1e-3f)); + // Nothing moved sideways or vertically: a mirrored or rotated basis would still have + // displaced along the normal, so this is here to catch the relief leaning instead. + CHECK_THAT(result.vertices[vi].x(), WithinAbs(wall.vertices[vi].x(), 1e-4f)); + CHECK_THAT(result.vertices[vi].z(), WithinAbs(wall.vertices[vi].z(), 1e-4f)); + } + } +} + TEST_CASE("The Cylindrical/Spherical patch frame is the bake's own, so a preview can share it", "[TextureDisplacement]") { // The fast preview reconstructs these two projections in the fragment shader and needs the very @@ -2104,6 +2710,157 @@ TEST_CASE("Cancelling while flat faces are harvested past the budget stops the d CHECK(canceled > full); } +TEST_CASE("Baking twice keeps the mesh closed and carries the first bake's colours across", + "[TextureDisplacement][Regression]") +{ + // Bake, colour part of what came back, bake again. The one-run pipeline rebuilds the topology, so + // the colour recorded against the first bake's mesh indexes triangles that no longer mean the same + // thing. Taken across by triangle id it lands on unrelated faces - a bake almost always grows the + // triangle count, so every stale id passes the only bound check deserialize() makes - which both + // scatters colour over surfaces nothing painted and destroys what the first pass laid down. + // transfer_facet_paint() carries it across by position instead - every triangle of the new mesh + // takes the state painted at its foot point on the old one - which is what makes successive bakes + // accumulate. See TextureDisplacementBakeJob, which is where the two meet. + constexpr float side = 20.f; + constexpr float refine = 0.5f; + constexpr float radius = 4.f; + const indexed_triangle_set cube = its_make_cube(side, side, side); + const Vec2f first_stroke(6.f, 6.f), second_stroke(14.f, 14.f); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.image_data = make_rgb_png_2x2(); + layer.tiling_scale = 5.f; + layer.depth_mm = 0.5f; + layer.color_enabled = true; + + TextureDisplacementOptions options; + options.pipeline_v2 = true; + options.v2_refine_mm = refine; + options.v2_max_triangles_k = 750; + + // Three primaries, so a palette entry is legible in the assertions below. + const std::array palette = { Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(0, 0, 1) }; + TextureColorRequest request; + request.quantize = [&palette](const Vec3f &rgb) { + int best = 0; + float bd = std::numeric_limits::max(); + for (int i = 0; i < 3; ++i) + if (const float d = (palette[size_t(i)] - rgb).squaredNorm(); d < bd) { bd = d; best = i; } + return best; + }; + + const auto bake = [&](const indexed_triangle_set &mesh, const Vec2f &stroke, std::vector &color) { + TextureDisplacementFacetsData facets; + facets[0] = paint_top_disc(mesh, side, stroke, radius); + TextureColorRequest req = request; + req.out_triangle = &color; + return build_texture_displacement(mesh, { layer }, facets, options, {}, &req); + }; + // The pipeline's own counters, over welded positions, so a mesh watertight only after welding + // still reads as closed. + const auto defects = [](const indexed_triangle_set &mesh) { + return TextureBake::count_edge_defects(TextureBake::to_soup(mesh)); + }; + + std::vector color1; + const indexed_triangle_set first = bake(cube, first_stroke, color1); + REQUIRE(first.indices.size() > cube.indices.size()); + REQUIRE(color1.size() == first.indices.size()); + const TextureBake::EdgeDefects d1 = defects(first); + INFO("first bake: " << d1.triangles << " triangles, " << d1.open << " open, " << d1.non_manifold << " non-manifold"); + CHECK(d1.open == 0); + CHECK(d1.non_manifold == 0); + + // The colour the first bake committed, as TextureDisplacementBakeJob writes it. + TriangleSelector::TriangleSplittingData painted_first; + { + const TriangleMesh mesh(first); + TriangleSelector selector(mesh); + for (size_t i = 0; i < color1.size(); ++i) + if (color1[i] != 0) + selector.set_facet(int(i), EnforcerBlockerType(color1[i])); + painted_first = selector.serialize(); + } + REQUIRE_FALSE(painted_first.bitstream.empty()); + + std::vector color2; + const indexed_triangle_set second = bake(first, second_stroke, color2); + REQUIRE(color2.size() == second.indices.size()); + REQUIRE(second.indices.size() != first.indices.size()); // the topology really did change + const TextureBake::EdgeDefects d2 = defects(second); + INFO("second bake: " << d2.triangles << " triangles, " << d2.open << " open, " << d2.non_manifold << " non-manifold"); + CHECK(d2.open == 0); + CHECK(d2.non_manifold == 0); + + // The commit: the earlier colour carried onto the new topology, this bake's own colour over it. + std::vector committed(second.indices.size(), 0); + { + const TriangleMesh mesh(second); + TriangleSelector selector(mesh); + const std::vector carried = transfer_facet_paint(first, painted_first, second); + REQUIRE(carried.size() == second.indices.size()); + for (size_t i = 0; i < carried.size(); ++i) + if (carried[i] != 0) + selector.set_facet(int(i), EnforcerBlockerType(carried[i])); + for (size_t i = 0; i < color2.size(); ++i) + if (color2[i] != 0) + selector.set_facet(int(i), EnforcerBlockerType(color2[i])); + for (int state = 1; state <= int(palette.size()); ++state) { + std::vector src; + if (selector.get_facets_strict(EnforcerBlockerType(state), &src).indices.empty()) + continue; + for (const int s : src) + if (s >= 0 && size_t(s) < committed.size() && committed[size_t(s)] == 0) + committed[size_t(s)] = uint8_t(state); + } + } + + // Just the triangles the first bake coloured, to ask "how far is this from colour that was really + // laid down" of anything the commit painted. + indexed_triangle_set coloured_first; + coloured_first.vertices = first.vertices; + for (size_t i = 0; i < color1.size(); ++i) + if (color1[i] != 0) + coloured_first.indices.push_back(first.indices[i]); + const AABBTreeIndirect::Tree3f tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set( + coloured_first.vertices, coloured_first.indices); + + // A triangle may carry colour the second bake did not produce only where the first bake's colour + // actually was. A foot point sits on the surface the bake started from, which the relief has since + // moved, and the transfer can widen the region by a triangle, so the margin is twice the edge + // length the bake was told to refine to; landing anywhere else means the mask was read against a + // mesh it no longer matches. + size_t own = 0, carried_over = 0, stranded = 0; + float worst = 0.f; + for (size_t i = 0; i < second.indices.size(); ++i) { + if (committed[i] == 0) + continue; + if (i < color2.size() && color2[i] != 0) { + CHECK(committed[i] == color2[i]); // this bake's own colour wins where it painted + ++own; + continue; + } + const stl_triangle_vertex_indices &t = second.indices[i]; + const Vec3f g = (second.vertices[size_t(t[0])] + second.vertices[size_t(t[1])] + + second.vertices[size_t(t[2])]) / 3.f; + size_t hit = 0; + Vec3f foot; + const float d = std::sqrt(std::max(0.f, AABBTreeIndirect::squared_distance_to_indexed_triangle_set( + coloured_first.vertices, coloured_first.indices, tree, g, hit, foot))); + worst = std::max(worst, d); + if (d <= 2.f * refine) + ++carried_over; + else + ++stranded; + } + INFO("committed: " << own << " from this bake, " << carried_over << " carried over, " << stranded + << " stranded, worst distance from the first bake's colour " << worst << " mm"); + CHECK(own > 0); + CHECK(carried_over > 0); // the first bake's colour survived the second + CHECK(stranded == 0); // and nothing was painted where neither bake put colour +} + TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[TextureDisplacement]") { // A stroke in each of the cube's two top triangles, baked one after the other. The first bake used @@ -2346,3 +3103,353 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); } + +// --------------------------------------------------------------------------------------------- +// The model's own UV channel, and the Override blend mode +// --------------------------------------------------------------------------------------------- + +// A grayscale image that is black in its left half and white in its right half, so where a sample +// lands along u is readable straight off the height it comes back with. +static std::shared_ptr> make_left_right_split_png(size_t w = 16, size_t h = 16) +{ + std::vector pixels(w * h); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) + pixels[y * w + x] = (x < w / 2) ? 0 : 255; + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + +// Where a sheet coordinate lands in uv: across [UV_LO, UV_LO + UV_SPAN] rather than the full [0, 1]. +// The inset keeps the tests off the exact edge of an untiled placement, where there is deliberately no +// texture at all rather than its border texel (see DecodedHeightTexture::sample()) - a real model's +// channel does reach 1.0, and a vertex sitting exactly there displaces by nothing on purpose. +static constexpr float UV_LO = 0.1f; +static constexpr float UV_SPAN = 0.8f; + +static float sheet_uv_at(float coord, float size, bool mirror = false) +{ + const float t = coord / size; + return UV_LO + UV_SPAN * (mirror ? 1.f - t : t); +} + +// One UV per triangle corner: u follows x, v follows y. `mirror_u` mirrors u, which is what the tests +// use to show the bake is really reading the channel rather than projecting the position. +static std::vector sheet_uv_channel(const indexed_triangle_set &sheet, float size, bool mirror_u = false) +{ + std::vector uv(sheet.indices.size() * 3); + for (size_t f = 0; f < sheet.indices.size(); ++f) + for (int k = 0; k < 3; ++k) { + const Vec3f &p = sheet.vertices[size_t(sheet.indices[f][k])]; + uv[f * 3 + size_t(k)] = Vec2f(sheet_uv_at(p.x(), size, mirror_u), sheet_uv_at(p.y(), size)); + } + return uv; +} + +TEST_CASE("Override blend replaces the displacement under it instead of combining with it", + "[TextureDisplacement]") +{ + CHECK_THAT(blend_displacement(3.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f)); + // Nothing underneath yet is still just the value, so an Override layer at the bottom of the + // stack behaves exactly like the Add the bake forces there. + CHECK_THAT(blend_displacement(0.f, 1.f, TextureBlendMode::Override), WithinAbs(1.f, 1e-6f)); + // The sign carries, and a zero sample really does flatten what is under it - that is the point. + CHECK_THAT(blend_displacement(3.f, -1.f, TextureBlendMode::Override), WithinAbs(-1.f, 1e-6f)); + CHECK_THAT(blend_displacement(3.f, 0.f, TextureBlendMode::Override), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("an Override layer leaves only its own relief where two layers overlap", "[TextureDisplacement]") +{ + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + + TextureDisplacementLayer base; + base.slot = 0; + base.depth_mm = 2.0f; + base.tiling_scale = 5.0f; + base.image_data = make_flat_gray_png(255); // full depth everywhere + + TextureDisplacementLayer second = base; + second.slot = 1; + second.depth_mm = 0.5f; + second.blend_mode = TextureBlendMode::Override; + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(cube); + facets[1] = facets[0]; + + const indexed_triangle_set overridden = build_texture_displacement(cube, {base, second}, facets, classic_options()); + REQUIRE(overridden.vertices.size() == cube.vertices.size()); + + // Add would have piled the two up to 2.5 mm; Override keeps the upper layer's 0.5 mm alone. + TextureDisplacementLayer added = second; + added.blend_mode = TextureBlendMode::Add; + const indexed_triangle_set summed = build_texture_displacement(cube, {base, added}, facets, classic_options()); + REQUIRE(summed.vertices.size() == cube.vertices.size()); + + for (size_t i = 0; i < cube.vertices.size(); ++i) { + if (!above_resting_plane(cube, i)) + continue; + CHECK_THAT((overridden.vertices[i] - cube.vertices[i]).norm(), WithinAbs(0.5f, 1e-3f)); + CHECK_THAT((summed.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.5f, 1e-3f)); + } +} + +TEST_CASE("the ModelUV mapping displaces through the model's own UV channel", "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set sheet = make_flat_sheet(size, 1.f); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.image_data = make_left_right_split_png(); + layer.tile_enabled = false; // the layout is the placement; nothing should repeat + layer.projection_method = TextureProjectionMethod::ModelUV; + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(sheet); + + const std::vector uv = sheet_uv_channel(sheet, size); + const std::vector flipped = sheet_uv_channel(sheet, size, /* mirror_u */ true); + + const indexed_triangle_set out = build_texture_displacement(sheet, {layer}, facets, classic_options(), {}, nullptr, + Transform3d::Identity(), nullptr, nullptr, &uv); + REQUIRE(out.vertices.size() == sheet.vertices.size()); + + // The image is black for u < 0.5 and white above it, and the channel's u rises with x across the + // sheet, so it comes out flat on its left half and raised by the full depth on its right. + size_t low = 0, high = 0; + for (size_t i = 0; i < sheet.vertices.size(); ++i) { + const float x = sheet.vertices[i].x(); + const float dz = out.vertices[i].z() - sheet.vertices[i].z(); + if (x < 0.4f * size) { + CHECK_THAT(dz, WithinAbs(0.f, 1e-3f)); + ++low; + } else if (x > 0.6f * size) { + CHECK_THAT(dz, WithinAbs(layer.depth_mm, 1e-3f)); + ++high; + } + } + REQUIRE(low > 0); + REQUIRE(high > 0); + + // Mirroring the channel mirrors the relief: the mapping really is the channel's, not a planar + // projection of the position (which would be unchanged by it). + const indexed_triangle_set mirrored = build_texture_displacement(sheet, {layer}, facets, classic_options(), {}, + nullptr, Transform3d::Identity(), nullptr, nullptr, + &flipped); + REQUIRE(mirrored.vertices.size() == sheet.vertices.size()); + for (size_t i = 0; i < sheet.vertices.size(); ++i) { + const float x = sheet.vertices[i].x(); + if (x < 0.4f * size) + CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(layer.depth_mm, 1e-3f)); + else if (x > 0.6f * size) + CHECK_THAT(mirrored.vertices[i].z() - sheet.vertices[i].z(), WithinAbs(0.f, 1e-3f)); + } +} + +TEST_CASE("a ModelUV layer falls back to Triplanar when the volume carries no UV channel", + "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set sheet = make_flat_sheet(size, 2.f); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.tiling_scale = 5.0f; + layer.image_data = make_flat_gray_png(255); + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(sheet); + + layer.projection_method = TextureProjectionMethod::Triplanar; + const indexed_triangle_set as_triplanar = build_texture_displacement(sheet, {layer}, facets, classic_options()); + layer.projection_method = TextureProjectionMethod::ModelUV; + const indexed_triangle_set no_channel = build_texture_displacement(sheet, {layer}, facets, classic_options()); + + REQUIRE(no_channel.vertices.size() == as_triplanar.vertices.size()); + for (size_t i = 0; i < as_triplanar.vertices.size(); ++i) + CHECK_THAT((no_channel.vertices[i] - as_triplanar.vertices[i]).norm(), WithinAbs(0.f, 1e-5f)); +} + +TEST_CASE("a bake with colour off on every layer reports no colour at all", "[TextureDisplacement]") +{ + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.tiling_scale = 5.0f; + layer.image_data = make_flat_rgb_png(200, 40, 10); // has colour to give... + layer.color_enabled = false; // ...but is not asked for it + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(cube); + + std::vector triangle_color; + TextureColorRequest request; + request.quantize = [](const Vec3f &) { return 0; }; // would colour everything, if asked + request.out_triangle = &triangle_color; + + const indexed_triangle_set out = build_texture_displacement(cube, {layer}, facets, classic_options(), {}, &request); + REQUIRE_FALSE(out.vertices.empty()); + // Every entry 0 ("this triangle takes no colour from the texture"), so the commit leaves whatever + // the model was already painted with exactly as it was. + for (const uint8_t entry : triangle_color) + CHECK(entry == 0); +} + +TEST_CASE("transfer_uv_channel resamples a UV channel onto a subdivided copy of the same surface", + "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set coarse = make_flat_sheet(size, 5.f); + const indexed_triangle_set fine = make_flat_sheet(size, 1.f); + const std::vector uv = sheet_uv_channel(coarse, size); + + const std::vector moved = transfer_uv_channel(coarse, uv, fine); + REQUIRE(moved.size() == fine.indices.size() * 3); + + // The channel is affine in position here (u = x/size, v = y/size), so a correct transfer + // reproduces it exactly at every corner of the finer mesh. + for (size_t f = 0; f < fine.indices.size(); ++f) + for (int k = 0; k < 3; ++k) { + const Vec3f &p = fine.vertices[size_t(fine.indices[f][k])]; + CHECK_THAT(moved[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f)); + CHECK_THAT(moved[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f)); + } + + // A mismatched channel is refused rather than read past its end. + CHECK(transfer_uv_channel(coarse, {Vec2f::Zero()}, fine).empty()); + CHECK(transfer_uv_channel(coarse, uv, indexed_triangle_set{}).empty()); +} + +TEST_CASE("the model's per-corner UVs survive a brush split and collapse to one per vertex", + "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set sheet = make_flat_sheet(size, 5.f); + const std::vector uv = sheet_uv_channel(sheet, size); + + // The whole sheet as a patch, with the source map the channel is read through. + const TriangleMesh tm(sheet); + TriangleSelector selector(tm); + for (int f = 0; f < int(sheet.indices.size()); ++f) + selector.set_facet(f, EnforcerBlockerType::ENFORCER); + std::vector source; + const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER, &source); + REQUIRE(patch.indices.size() == source.size()); + + const std::vector corner = compute_model_corner_uvs(sheet, uv, patch, source); + REQUIRE(corner.size() == patch.indices.size() * 3); + // An unsplit patch triangle *is* its source triangle, so its corners reproduce the channel. + for (size_t f = 0; f < patch.indices.size(); ++f) + for (int k = 0; k < 3; ++k) { + const Vec3f &p = patch.vertices[size_t(patch.indices[f][k])]; + CHECK_THAT(corner[f * 3 + size_t(k)].x(), WithinAbs(sheet_uv_at(p.x(), size), 1e-4f)); + CHECK_THAT(corner[f * 3 + size_t(k)].y(), WithinAbs(sheet_uv_at(p.y(), size), 1e-4f)); + } + + const std::vector per_vertex = collapse_corner_uvs(patch, corner); + REQUIRE(per_vertex.size() == patch.vertices.size()); + for (size_t i = 0; i < patch.vertices.size(); ++i) { + CHECK_THAT(per_vertex[i].x(), WithinAbs(sheet_uv_at(patch.vertices[i].x(), size), 1e-4f)); + CHECK_THAT(per_vertex[i].y(), WithinAbs(sheet_uv_at(patch.vertices[i].y(), size), 1e-4f)); + } + + // Mismatched inputs report nothing rather than guessing. + CHECK(compute_model_corner_uvs(sheet, {Vec2f::Zero()}, patch, source).empty()); + CHECK(compute_model_corner_uvs(sheet, uv, patch, {}).empty()); + CHECK(collapse_corner_uvs(patch, {Vec2f::Zero()}).empty()); +} + +TEST_CASE("facet paint is carried onto a rebuilt mesh by each facet's foot point", "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f); + + // Two filaments, split down the middle - the pattern the transfer has to reproduce. + const TriangleMesh coarse_mesh(coarse); + TriangleSelector painter(coarse_mesh); + for (size_t t = 0; t < coarse.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = coarse.indices[t]; + const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() + + coarse.vertices[size_t(tri[2])].x()) / 3.f; + painter.set_facet(int(t), x < 0.5f * size ? EnforcerBlockerType::Extruder1 : EnforcerBlockerType::Extruder2); + } + const TriangleSelector::TriangleSplittingData paint = painter.serialize(); + + // A finer mesh, lifted clear of the original surface the way a bake's relief lifts it. This is + // what ModelVolume::restore_painting()'s 0.01 mm overlap remap cannot follow. + indexed_triangle_set lifted = make_flat_sheet(size, 1.f); + for (Vec3f &v : lifted.vertices) + v.z() += 0.5f; + + const std::vector state = transfer_facet_paint(coarse, paint, lifted); + REQUIRE(state.size() == lifted.indices.size()); + + size_t left = 0, right = 0; + for (size_t t = 0; t < lifted.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = lifted.indices[t]; + const float x = (lifted.vertices[size_t(tri[0])].x() + lifted.vertices[size_t(tri[1])].x() + + lifted.vertices[size_t(tri[2])].x()) / 3.f; + // Only away from the split: a facet straddling it may legitimately land on either side. + if (x < 0.4f * size) { + CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder1)); + ++left; + } else if (x > 0.6f * size) { + CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder2)); + ++right; + } + } + REQUIRE(left > 0); + REQUIRE(right > 0); + + // An unpainted source has nothing to carry, which is not the same as carrying NONE everywhere: + // the caller has to tell those apart to decide between resetting the annotation and writing one. + CHECK(transfer_facet_paint(coarse, TriangleSelector::TriangleSplittingData{}, lifted).empty()); + CHECK(transfer_facet_paint(coarse, paint, indexed_triangle_set{}).empty()); +} + +TEST_CASE("an unpainted part of the source carries no paint onto the rebuilt mesh", "[TextureDisplacement]") +{ + constexpr float size = 10.f; + const indexed_triangle_set coarse = make_flat_sheet(size, 2.5f); + + // Only the left half is painted at all; the right half must come back NONE rather than borrowing + // the nearest painted colour from across the model. + const TriangleMesh coarse_mesh(coarse); + TriangleSelector painter(coarse_mesh); + for (size_t t = 0; t < coarse.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = coarse.indices[t]; + const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() + + coarse.vertices[size_t(tri[2])].x()) / 3.f; + if (x < 0.5f * size) + painter.set_facet(int(t), EnforcerBlockerType::Extruder3); + } + + const std::vector state = transfer_facet_paint(coarse, painter.serialize(), coarse); + REQUIRE(state.size() == coarse.indices.size()); + for (size_t t = 0; t < coarse.indices.size(); ++t) { + const stl_triangle_vertex_indices &tri = coarse.indices[t]; + const float x = (coarse.vertices[size_t(tri[0])].x() + coarse.vertices[size_t(tri[1])].x() + + coarse.vertices[size_t(tri[2])].x()) / 3.f; + if (x < 0.4f * size) + CHECK(state[t] == uint8_t(EnforcerBlockerType::Extruder3)); + else if (x > 0.6f * size) + CHECK(state[t] == uint8_t(EnforcerBlockerType::NONE)); + } +}