Merge branch 'main' into image-gizmo

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

After

Width:  |  Height:  |  Size: 340 B

@@ -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);
}
}
@@ -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)
@@ -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);
}
}
@@ -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)
+3 -2
View File
@@ -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();
+22
View File
@@ -354,6 +354,24 @@ Model Model::read_from_file(const std::string&
ObjInfo obj_info;
ObjParser::MtlData mtl_data;
result = load_obj(input_file.c_str(), &model, obj_info, message, nullptr, &mtl_data);
// Keep the model's own UV channel on the volume, whatever the colour import then does with
// the mesh: it is what TextureProjectionMethod::ModelUV maps a texture-displacement layer
// through, so a model that arrives with a UV layout is textured on that layout instead of on
// a fresh unwrap. load_obj() fills obj_info.uvs per face of the mesh it built, in the same
// order (and only when every face named a known material, hence the size check). V is flipped
// on the way in, to the convention DecodedHeightTexture samples in - the same flip
// obj_to_textured_mesh() does for the colour importer.
if (result && !obj_info.uvs.empty() && !model.objects.empty() && !model.objects.back()->volumes.empty()) {
ModelVolume &volume = *model.objects.back()->volumes[0];
const indexed_triangle_set &its = volume.mesh().its;
if (obj_info.uvs.size() == its.indices.size()) {
volume.texture_uv.resize(its.indices.size() * 3);
for (size_t fi = 0; fi < obj_info.uvs.size(); ++fi)
for (int k = 0; k < 3; ++k)
volume.texture_uv[fi * 3 + size_t(k)] =
Vec2f(obj_info.uvs[fi][size_t(k)].x(), 1.f - obj_info.uvs[fi][size_t(k)].y());
}
}
if (result && obj_info.has_uv_png && !obj_info.uvs.empty() && !model.objects.empty()) {
// Textured OBJ: hand the mesh + materials to the texture-to-color importer
// instead of the flat per-face colour dialog.
@@ -2134,6 +2152,10 @@ void ModelVolume::reset_extra_facets()
// than left referring to a mesh that no longer matches it.
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
this->texture_displacement_facet(i).reset();
// Same reasoning for the model's own UV channel: it is indexed by triangle, so against a mesh
// this no longer matches it would map the texture by another mesh's triangle numbering. A caller
// that can carry it across - see transfer_uv_channel() - puts it back after this.
this->reset_texture_uv();
}
std::optional<TriangleSelector::SavedPainting> ModelVolume::save_painting() const
+23 -2
View File
@@ -975,6 +975,26 @@ public:
// they are not a property of any single layer.
TextureDisplacementOptions texture_displacement_options;
// The model's own texture coordinates, as the file it was imported from carried them: three per
// triangle of mesh().its, in its triangle order (corner k of triangle i at 3i+k), V already
// flipped to the sampling convention (V = 0 is the image's top row). Empty when the source had
// none, which is every STL and every 3mf - only a textured OBJ brings one in today.
//
// Per corner rather than per vertex because that is what a UV layout is: the two sides of a seam
// are the same vertex with two different coordinates, and collapsing them would smear the texture
// across every island boundary. TextureProjectionMethod::ModelUV maps through it, which is what
// lets a model that already carries a UV channel be textured on the artist's own layout instead
// of on a fresh unwrap.
//
// Indexed by triangle, so anything that *replaces* the mesh has to bring it across (or drop it):
// see transfer_uv_channel(), and reset_texture_uv() for the cases that cannot.
std::vector<Vec2f> texture_uv;
bool has_texture_uv() const { return !texture_uv.empty() && texture_uv.size() == mesh().its.indices.size() * 3; }
// Forgets the channel. For a mesh change that nothing can carry it across - a split, a boolean -
// where leaving it would map the texture by another mesh's triangle numbering.
void reset_texture_uv() { texture_uv.clear(); }
// Save painting data before reset_extra_facets() discards it.
// Used for replacing mesh without losing painting data.
// Only for model parts (not modifiers/connectors).
@@ -1240,6 +1260,7 @@ private:
texture_displacement_facets_6(other.texture_displacement_facets_6), texture_displacement_facets_7(other.texture_displacement_facets_7),
texture_displacement_layers(other.texture_displacement_layers),
texture_displacement_options(other.texture_displacement_options),
texture_uv(other.texture_uv),
cut_info(other.cut_info), text_configuration(other.text_configuration), emboss_shape(other.emboss_shape)
{
assert(this->id().valid());
@@ -1338,7 +1359,7 @@ private:
cereal::load_by_value(ar, f);
mesh_changed |= tf != f.timestamp();
}
ar(texture_displacement_layers, texture_displacement_options);
ar(texture_displacement_layers, texture_displacement_options, texture_uv);
cereal::load_by_value(ar, config);
cereal::load(ar, text_configuration);
cereal::load(ar, emboss_shape);
@@ -1362,7 +1383,7 @@ private:
cereal::save_by_value(ar, fuzzy_skin_facets);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
cereal::save_by_value(ar, texture_displacement_facet(i));
ar(texture_displacement_layers, texture_displacement_options);
ar(texture_displacement_layers, texture_displacement_options, texture_uv);
cereal::save_by_value(ar, config);
cereal::save(ar, text_configuration);
cereal::save(ar, emboss_shape);
File diff suppressed because it is too large Load Diff
+205 -8
View File
@@ -86,6 +86,17 @@ enum class TextureProjectionMethod : int
// that is inherent to view projection and is the user's call, not a bug. The projector's two
// in-plane axes live in TextureDisplacementLayer::view_project_right/up.
ViewProjected = 4,
// The model's own texture coordinates, as the file it was imported from carried them (see
// ModelVolume::texture_uv). Nothing is unwrapped or projected: the artist's layout *is* the
// placement, so a texture authored against that layout - the model's own albedo map, say - lands
// exactly where it was painted. Automatically picked for a new layer on a volume that has a UV
// channel, since re-unwrapping a model that already carries one can only be worse than using it.
//
// The uv is already normalized, so unlike every method above it is not measured in millimetres:
// "Tile size" and the non-square aspect correction do not apply to it (the layer's rotation and
// offset still do). Falls back to Triplanar when the volume has no channel, exactly as LSCM falls
// back when a patch cannot be flattened.
ModelUV = 5,
};
// Dihedral angle (degrees) above which an edge between two painted triangles becomes a chart seam
@@ -121,6 +132,11 @@ enum class TextureBlendMode : int
Subtract = 1, // acc - value (carve this layer's relief out of the layers below)
Multiply = 2, // acc * (value / 1mm) (mask/modulate the layers below by this layer)
Divide = 3, // acc / (value / 1mm) (inverse mask; guarded against a zero/near-zero divisor)
// acc = value: this layer replaces what is under it instead of combining with it, so where it is
// painted only its own relief remains. The image editor's "Normal" mode, under the name the panel
// uses. Note the stack's *colour* already behaves this way - a higher slot's colour overwrites a
// lower one's where they overlap - so this is what makes the displacement agree with it.
Override = 4,
};
// Combines one layer's signed displacement `value` (mm) into `accumulated` (mm) per `mode`.
@@ -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<TextureIsland> &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<std::pair<int, Vec2f>> 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<uint8_t> transfer_facet_paint(const indexed_triangle_set &src_mesh,
const TriangleSelector::TriangleSplittingData &src_paint,
const indexed_triangle_set &dst_mesh);
// Area-weighted centroid and average normal of a layer's currently painted patch, in mesh-local
// coordinates - the same measurements build_texture_displacement() uses to pick its dominant
// projection axis. Used by the GUI to anchor the on-canvas "adjust texture placement" gizmo to
@@ -768,6 +902,55 @@ std::vector<Vec2f> compute_lscm_uvs(const indexed_triangle_set &patch, const Tex
// Returns an empty vector if the patch has no triangles or the unwrap carries no source_face map.
std::vector<Vec2f> compute_lscm_corner_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer);
// Three UVs per patch triangle, read out of the model's *own* UV channel - the
// TextureProjectionMethod::ModelUV counterpart of compute_lscm_corner_uvs(), and laid out the same way
// (corner k of triangle i at index 3i+k).
//
// `model_uv` is the volume's channel: three uvs per triangle of `base_mesh`, in its triangle order
// (see ModelVolume::texture_uv). `patch_source` maps each patch triangle back to the base-mesh
// triangle it was cut from - TriangleSelector::get_facets_strict()'s out_source. A patch triangle is
// always a piece of exactly one base triangle, so each of its corners is interpolated barycentrically
// inside that triangle's three uvs. That is what keeps a sub-triangle the brush split (whose corners
// are not base-mesh vertices at all) on the texture, and what keeps the two sides of a UV seam
// reading from their own island instead of from one averaged coordinate.
//
// Returns an empty vector when the channel does not match `base_mesh`, when `patch_source` does not
// match `patch`, or when either is empty - the callers treat that as "no placement" and fall back,
// exactly as they do for an unwrap that failed.
std::vector<Vec2f> compute_model_corner_uvs(const indexed_triangle_set &base_mesh,
const std::vector<Vec2f> &model_uv,
const indexed_triangle_set &patch,
const std::vector<int> &patch_source);
// One UV per patch vertex, collapsed from a per-corner array: the first corner to reach a vertex wins.
// This is the per-corner/per-vertex split compute_lscm_uvs() documents, applied to any corner array -
// displacement is per-vertex, so a vertex on a seam has to settle on one of its copies.
// Returns an empty vector if `corner` is not three per patch triangle.
std::vector<Vec2f> collapse_corner_uvs(const indexed_triangle_set &patch, const std::vector<Vec2f> &corner);
// Resamples a per-corner UV channel from `src_mesh` onto `dst_mesh`, for when something replaces a
// volume's mesh and the model's own UV channel has to come along: the colour import's painted mesh,
// the gizmo's remesh/subdivide, the default bake pipeline's rebuild. Each destination corner takes the
// uv of its closest point on the source surface, interpolated barycentrically inside the source
// triangle it lands on - the same transfer LscmLookup does per point, run over every corner.
//
// Exact wherever the two meshes describe the same surface, which is the case for every caller here
// (they subdivide, repair or displace it, they do not reshape it). A corner that lands nowhere near
// the source - farther than `max_distance` (negative: no limit) - takes (0, 0).
//
// One thing a positional transfer cannot reproduce: a *seam* vertex, where one position carries a
// different uv in each island that meets there. Position alone does not say which island a corner
// belongs to, so corners on a seam all take whichever island the nearest-triangle lookup lands on,
// and the texture can be off by up to one triangle in a band along the island borders. Doing better
// needs a triangle-to-triangle correspondence, which only some of the stages that replace a mesh
// have (a remesh has none at all).
//
// Returns an empty vector when `src_uv` is not three per source triangle, or when either mesh is empty.
std::vector<Vec2f> transfer_uv_channel(const indexed_triangle_set &src_mesh,
const std::vector<Vec2f> &src_uv,
const indexed_triangle_set &dst_mesh,
float max_distance = -1.f);
// The TextureDisplacementLayer::lscm_uv_overrides key for one unwrapped vertex (an index into PatchUnwrap::uvs).
inline int lscm_uv_override_key(int unwrapped_vertex) { return -(unwrapped_vertex + 1); }
@@ -826,6 +1009,10 @@ using TextureDisplacementFacetsData = std::array<TriangleSelector::TriangleSplit
//
// `color`, when given, also reports which filament each triangle should print in - see
// TextureColorRequest.
//
// `model_uv`, when given, is the volume's own UV channel (three uvs per base-mesh triangle, see
// ModelVolume::texture_uv) and is what a TextureProjectionMethod::ModelUV layer maps through. A layer
// set to ModelUV without it falls back to Triplanar.
struct TextureColorRequest
{
// RGB -> palette index. Supplied by the GUI, which owns both the perceptual matching and the list
@@ -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<Vec2f> *model_uv = nullptr);
// `volume`'s mesh coordinates -> world millimetres: its first instance's transform times its own.
// The mesh is shared by every instance, so a multi-instance object can only be baked for one of
@@ -975,11 +1164,15 @@ ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
ColorQuantizeFn quantize,
ColorQuantizeFn quantize_pure = nullptr);
ColorQuantizeFn quantize_pure = nullptr,
// The model's own UV channel, for ModelUV layers; see
// build_texture_displacement().
const std::vector<Vec2f> *model_uv = nullptr);
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data);
const TextureDisplacementFacetsData &facets_data,
const std::vector<Vec2f> *model_uv = nullptr);
// Uniformly subdivides `mesh` (every triangle recursively split into 4 via edge midpoints, using a
// shared cache so a midpoint is computed once and reused by both triangles on either side of that
@@ -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<Vec2f> uv;
// The remesh landed but no layer's paint survived being carried onto it. Nothing is committed:
// everything downstream is driven by that paint, so baking on would bake a flat mesh.
bool paint_lost = false;
+21
View File
@@ -25,6 +25,7 @@
#include "TextureToColor/ColorUtils.hpp"
#include "Model.hpp"
#include "TextureDisplacement.hpp"
#include "TextureToColor/TriMesh.hpp"
#include "TriangleMesh.hpp"
#include "TriangleSelector.hpp"
@@ -554,9 +555,29 @@ bool apply_painted_mesh_to_volume(
<< ", unit_scale=" << unit_scale << ")";
}
// The model's own UV channel is indexed by triangle, and this replaces the triangles, so it has
// to be resampled onto them. Kept rather than dropped because it is the layout the model was
// authored against: a texture-displacement layer set to ModelUV maps through it, and the colour
// import is exactly the path a model that carries one arrives by.
const indexed_triangle_set old_its = volume.has_texture_uv() ? volume.mesh().its : indexed_triangle_set{};
const std::vector<Vec2f> old_uv = volume.texture_uv;
volume.set_mesh(std::move(new_mesh));
volume.calculate_convex_hull();
// Resampled here and not further down, while the two meshes still share a frame: the painted mesh
// was just shifted by the same source.mesh_offset the old one was centred by, so the only thing
// between them is whatever drift the repair introduced. The re-centering below translates the mesh
// again, and a translation is exactly what a closest-point transfer cannot see through - it would
// read every uv off a point that far away. A uv is a coordinate in the texture, so it does not care
// about either shift; it just has to be read before the second one happens.
if (!old_its.indices.empty())
volume.texture_uv = transfer_uv_channel(old_its, old_uv, volume.mesh().its);
else
// No channel to carry, so make sure none is left behind either: a leftover of the right length
// would read as a valid channel against a mesh it was never measured on.
volume.reset_texture_uv();
// Re-center the replaced mesh so its bbox center sits at the origin,
// matching what center_geometry_after_creation did for the original mesh.
// CGAL repair / subdivision may shift the bbox center (drift); without
+6 -3
View File
@@ -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<PathVertex>().swap(m_vertices);
std::vector<float>().swap(m_vertices_colors);
for (std::vector<float>& times : m_layer_start_times)
std::vector<float>().swap(times);
std::vector<uint32_t>().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<float>& times : m_layer_start_times)
ret += STDVEC_MEMSIZE(times, float);
ret += STDVEC_MEMSIZE(m_layer_first_vertex, uint32_t);
+22 -3
View File
@@ -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
+7
View File
@@ -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(); }
@@ -43,6 +43,7 @@
#include <cstdint>
#include <functional>
#include <chrono>
#include <future>
#include <cstdio>
#include <glad/gl.h>
#include <imgui.h>
@@ -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<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer) const
std::vector<Vec2f> GLGizmoTextureDisplacement::model_uv_corners(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
{
const ModelVolume *mv = texture_volume();
if (layer.projection_method != TextureProjectionMethod::ModelUV || patch_source == nullptr || mv == nullptr ||
!mv->has_texture_uv())
return {};
// The channel is indexed against the volume's *local* mesh, and a uv is a coordinate in the
// texture rather than on the model, so unlike every projected method this never goes through
// patch_in_world() - there is no millimetre measurement here for a world scale to change.
std::vector<Vec2f> uv = compute_model_corner_uvs(mv->mesh().its, mv->texture_uv, local_patch, *patch_source);
for (Vec2f &p : uv)
// Already a uv: no tiling scale, no aspect correction - see apply_uv_transform().
p = apply_uv_transform(p, layer, 1.f, /* normalized_uv */ true);
return uv;
}
std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
{
if (layer.projection_method == TextureProjectionMethod::ModelUV)
// Collapsed to one per vertex the same way the unwrap is - a vertex has one position, so the
// displacement can only read one height for it.
return collapse_corner_uvs(local_patch, model_uv_corners(local_patch, layer, patch_source));
// The bake maps the texture in world millimetres, so "Tile size (mm)" means the same thing on a
// scaled instance as it does on an untouched one (see build_texture_displacement()). Everything
// that has to agree with the bake - the fast preview's uvs, the checker/distortion overlays -
@@ -1286,8 +1323,9 @@ std::vector<Vec2f> 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<Vec2f> GLGizmoTextureDisplacement::compute_layer_corner_uvs(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer) const
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const
{
if (layer.projection_method == TextureProjectionMethod::ModelUV)
return model_uv_corners(local_patch, layer, patch_source);
if (layer.projection_method == TextureProjectionMethod::LSCM) {
const indexed_triangle_set patch = patch_in_world(local_patch);
const float aspect = layer_texture_aspect(layer);
@@ -1328,7 +1369,7 @@ std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_corner_uvs(const in
return uv;
}
// Single-valued per point: fan the per-vertex result out over the corners.
const std::vector<Vec2f> per_vertex = compute_layer_vertex_uvs(local_patch, layer);
const std::vector<Vec2f> per_vertex = compute_layer_vertex_uvs(local_patch, layer, patch_source);
if (per_vertex.size() != local_patch.vertices.size())
return {};
std::vector<Vec2f> corner(local_patch.indices.size() * 3);
@@ -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<int> 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<Vec2f> corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active) : std::vector<Vec2f>{};
std::vector<Vec2f> corner_uv = active != nullptr ? compute_layer_corner_uvs(patch, *active, &patch_source) :
std::vector<Vec2f>{};
m_shaded_preview_uses_vertex_uv = corner_uv.size() == patch.indices.size() * 3;
if (!m_shaded_preview_uses_vertex_uv)
corner_uv.clear();
@@ -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<int> patch_source; // only ModelUV reads it; see compute_layer_vertex_uvs()
const indexed_triangle_set patch =
m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER, &patch_source);
if (patch.indices.empty())
return;
// Everything below works in the *patch's* vertex space, not the mesh's. Those agree only until a
@@ -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<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer);
std::vector<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer, &patch_source);
const bool have_uvs = uv.size() == patch.vertices.size();
m_uvcheck_uses_vertex_uv = have_uvs;
m_uvcheck_projection_mode = have_uvs ? 0 :
@@ -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<Vec2f> corner_uv = compute_layer_corner_uvs(patch, *layer);
const std::vector<Vec2f> corner_uv = compute_layer_corner_uvs(patch, *layer, &patch_source);
const bool have_corner_uvs = have_uvs && corner_uv.size() == patch.indices.size() * 3;
// Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible.
@@ -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<GLTexture> 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<TextureLibraryEntry> 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<TextureDisplacementLayer> &layers, const TextureDisplacementPrepareParams &params,
const std::vector<TextureDisplacementLayer> &layers, const std::vector<Vec2f> &model_uv,
const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings, const DisplacementProgressFn &progress,
BakeStageRecorder *debug)
{
@@ -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<Vec2f> uv_stage = uv_now();
const std::vector<Vec2f> *uv_ptr = uv_stage.empty() ? nullptr : &uv_stage;
HeightFieldSampler sampler;
if (params.subdiv_feature)
sampler = make_combined_displacement_sampler(mesh.its, layers, current);
sampler = make_combined_displacement_sampler(mesh.its, layers, current, uv_ptr);
// Colour boundaries need triangles of their own - the chord test cannot see them, since
// the height field is perfectly smooth across a change of filament.
ColorFieldSampler color;
@@ -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<uint8_t> cut_region(mesh.its.indices.size(), 0);
for (size_t i = 0; i < cut_region.size() && i < source.size(); ++i)
cut_region[i] = (region[size_t(source[i])] & REFINE_PAINTED) ? 1 : 0;
const std::vector<Vec2f> uv_refined = uv_now();
const HeightFieldSampler refined_sampler =
make_combined_displacement_sampler(mesh.its, layers, current);
make_combined_displacement_sampler(mesh.its, layers, current,
uv_refined.empty() ? nullptr : &uv_refined);
std::vector<int> cut_source;
size_t cuts = 0;
indexed_triangle_set cut = cut_mesh_at_steps(mesh.its, cut_region, refined_sampler,
@@ -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<TextureProjectionMethod>(mi);
const wxString na = (method == TextureProjectionMethod::ModelUV) ? model_uv_na : wxString();
if (icon_toggle(830 + mi, mappings[mi].file, layer.projection_method == method, icon_sm, mappings[mi].label,
mappings[mi].tip) &&
mappings[mi].tip, na) &&
layer.projection_method != method) {
// Capture the current view the moment "From view" is chosen, so it does something
// sensible immediately rather than projecting from a stale/default direction.
@@ -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<int>(layer.blend_mode);
int blend_mode = 0;
if (!base_layer)
for (int bi = 0; bi < int(IM_ARRAYSIZE(blend_modes)); ++bi)
if (blend_modes[bi] == layer.blend_mode)
blend_mode = bi;
ImGui::SetNextItemWidth(-(card_pad + gap_s + icon_sm));
m_imgui->disabled_begin(base_layer);
if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer) {
layer.blend_mode = static_cast<TextureBlendMode>(blend_mode);
if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer &&
blend_mode >= 0 && blend_mode < int(IM_ARRAYSIZE(blend_modes))) {
layer.blend_mode = blend_modes[blend_mode];
m_preview_params_dirty = true;
}
m_imgui->disabled_end();
@@ -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
@@ -23,6 +23,7 @@
#include "libslic3r/Point.hpp"
#include <imgui.h>
#include <map>
#include <future>
#include <memory>
#include <optional>
#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<TextureDisplacementLayer> &layers,
// `base`'s own UV channel, when it has one
// (ModelVolume::texture_uv). Resampled onto
// the result and reported back in it, and fed
// to the samplers so a ModelUV layer drives
// the refinement through its own layout.
const std::vector<Vec2f> &model_uv,
const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings,
const DisplacementProgressFn &progress,
@@ -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<GLTexture> 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<DecodedHeightTexture> 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<int> &groups, int a, int b, int chart_count);
// Final per-vertex texture uv for the projections the shader can't reconstruct itself - LSCM (an
// unwrap) and ViewProjected (a projector plane the shader doesn't know). One entry per patch/base
// vertex, already through apply_uv_transform(). Empty for Triplanar/Cylindrical/Spherical, which
// the shader projects on its own. Shared by the shaded preview and the UV-check overlay.
// unwrap), ModelUV (the model's own channel) and ViewProjected (a projector plane the shader
// doesn't know). One entry per patch/base vertex, already through apply_uv_transform(). Empty for
// Triplanar/Cylindrical/Spherical, which the shader projects on its own. Shared by the shaded
// preview and the UV-check overlay.
//
// `patch_source` maps each patch triangle back to its base-mesh triangle
// (TriangleSelector::get_facets_strict()'s out_source). Only ModelUV needs it - its channel is per
// base triangle - and without it a ModelUV layer reports no uvs and the preview falls back.
std::vector<Vec2f> compute_layer_vertex_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer) const;
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source = nullptr) const;
// The same, but three UVs per patch triangle (corner 0..2 of triangle i at 3i..3i+2). This is what
// the flat, unshared-vertex preview meshes actually want: under LSCM a seam vertex has a different
// UV in each island it borders, so collapsing to one per vertex handed a triangle at an unjoined
// seam its neighbour's placement - one visibly skewed triangle per face. Every other projection is
// single-valued per point, so there a corner's UV is just its vertex's.
std::vector<Vec2f> compute_layer_corner_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer) const;
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source = nullptr) const;
// The model's own channel over `local_patch`, already through apply_uv_transform() - three per
// patch triangle. Empty when the volume has no channel, or when `patch_source` is missing. Shared
// by the two functions above so the per-vertex and per-corner forms can never disagree.
std::vector<Vec2f> model_uv_corners(const indexed_triangle_set &local_patch,
const TextureDisplacementLayer &layer,
const std::vector<int> *patch_source) const;
// The in-shader projection for `layer` (0 Triplanar, 1 Cylindrical, 2 Spherical) plus, for the two
// wrapping ones, the patch centroid and cylinder axis they wrap around - in the texture frame the
// shaders project in. Taken from the bake's own texture_displacement_patch_frame(), so a preview
@@ -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
@@ -8,6 +8,7 @@
#include "libslic3r/TextureDisplacement.hpp"
#include <exception>
#include <cstddef>
#include <cstdint>
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <memory>
@@ -26,6 +27,7 @@
#include "slic3r/Utils/UndoRedo.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <array>
namespace Slic3r::GUI {
@@ -74,7 +76,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
}
return true;
},
color, m_input.volume_to_world, nullptr, &m_stats));
color, m_input.volume_to_world, nullptr, &m_stats,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv));
// Always finish at 100: this is what closes the notification. Reported even on cancel, where
// build_texture_displacement() returns an empty mesh and finalize() commits nothing.
@@ -126,16 +129,71 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
{ mix.a_percent(), 100 - mix.a_percent() });
});
// Whether the bake replaced the triangles or only moved the vertices it was given. The classic
// path is topology-preserving by contract, so every per-triangle annotation on the volume still
// means what it did; the default pipeline refines, cuts and simplifies, so none of them do.
// Both are checked: the flag is what the bake promised, the count is what it delivered. Only
// the triangles matter - the annotations are per facet and reference no vertex index - so a
// vertex count that changed on its own (the bake compacts unreferenced vertices away) is not a
// reason to remap anything.
const bool topology_changed = m_input.options.pipeline_v2 ||
m_result.its.indices.size() != m_input.base_mesh.indices.size();
// Carry the volume's existing paint - the model's own colours above all - across that rebuild.
// Without it a bake left the mmu_segmentation_facets bitstream pointing at a triangle set that
// no longer existed, which is what scrambled the colours of an imported coloured model even
// with colour switched off on every layer and nothing in the bake that was meant to touch them.
//
// transfer_facet_paint() rather than ModelVolume::restore_painting(): the latter's remap wants
// a target triangle overlapping the source paint to within 0.01 mm, which the baked surface is
// nowhere near - it has moved by the relief depth. Nothing to do when the topology is intact:
// triangle i is still triangle i, so the paint is already exactly right and carrying it at all
// could only lose detail.
std::array<TriangleSelector::TriangleSplittingData, 4> before;
if (topology_changed) {
before[0] = volume->supported_facets.get_data();
before[1] = volume->seam_facets.get_data();
before[2] = volume->mmu_segmentation_facets.get_data();
before[3] = volume->fuzzy_skin_facets.get_data();
}
volume->set_mesh(std::move(m_result));
volume->set_new_unique_id();
volume->calculate_convex_hull();
if (topology_changed) {
const std::array<FacetsAnnotation *, 4> target{ &volume->supported_facets, &volume->seam_facets,
&volume->mmu_segmentation_facets,
&volume->fuzzy_skin_facets };
for (size_t ch = 0; ch < target.size(); ++ch) {
const std::vector<uint8_t> state =
transfer_facet_paint(m_input.base_mesh, before[ch], volume->mesh().its);
// Nothing was painted in this channel, or there is nothing to paint it onto: either
// way the annotation must not keep describing the mesh that is gone.
if (state.empty()) {
target[ch]->reset();
continue;
}
TriangleSelector selector(volume->mesh());
bool any = false;
for (size_t i = 0; i < state.size(); ++i)
if (state[i] != 0) {
selector.set_facet(int(i), EnforcerBlockerType(state[i]));
any = true;
}
if (any)
target[ch]->set(selector);
else
target[ch]->reset();
}
}
// Colour lands in mmu_segmentation_facets, merged *over* whatever is already painted there
// rather than replacing it: a triangle the texture does not colour keeps its existing filament,
// and one the user never painted at all stays at NONE, which already means "the volume's own
// filament". That is what confines the effect to the painted area without having to invent a
// colour for everything outside it. Safe to index straight onto the new mesh - the bake is
// topology-preserving, so triangle i is still triangle i.
// colour for everything outside it. What was already painted is whatever the transfer above
// left in the annotation, so this bake's colour lands on top of it rather than instead of it.
if (!m_triangle_color.empty() && m_triangle_color.size() == volume->mesh().its.indices.size()) {
TriangleSelector selector(volume->mesh());
const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data();
@@ -159,7 +217,6 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
// pipeline, which rebuilds and then simplifies the mesh. A mask left behind against the old
// topology is exactly what makes the gizmo reload an empty selector over a non-empty mask and
// then erase it on the next flush - see GLGizmoTextureDisplacement::update_from_model_object().
const bool topology_changed = m_input.base_mesh.indices.size() != volume->mesh().its.indices.size();
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const auto it = std::find_if(m_input.layers.begin(), m_input.layers.end(),
[slot](const TextureDisplacementLayer &l) { return l.slot == slot; });
@@ -167,6 +224,14 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
volume->texture_displacement_facet(slot).reset();
}
// The model's own UV channel. Resampled onto a rebuilt mesh from the surface the bake started
// from: a displaced vertex came from its foot point on that surface, which is the position its
// uv belongs to - so no distance limit, the relief depth is exactly how far it has moved.
if (!m_input.model_uv.empty())
volume->texture_uv = topology_changed ? transfer_uv_channel(m_input.base_mesh, m_input.model_uv,
volume->mesh().its) :
m_input.model_uv;
ModelObject *object = volume->get_object();
if (object == nullptr)
return;
@@ -219,6 +284,8 @@ void queue_texture_displacement_bake(const ModelVolume &volume, const TextureCol
input.take_snapshot = take_snapshot;
input.volume_id = volume.id();
input.base_mesh = volume.mesh().its;
if (volume.has_texture_uv())
input.model_uv = volume.texture_uv;
input.layers = volume.texture_displacement_layers;
input.options = volume.texture_displacement_options;
input.volume_to_world = texture_displacement_volume_to_world(volume);
@@ -34,6 +34,9 @@ struct TextureDisplacementBakeInput
// writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU
// paint gizmo writes - alongside the displaced geometry.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), empty when it has none: what a ModelUV
// layer maps through, and what the commit puts back on the baked mesh.
std::vector<Vec2f> model_uv;
// Whether this job pushes its own undo step when it commits. False when the caller has already
// taken one that is meant to cover the displacement as well - Standard mode's Bake, which remeshes
// and subdivides first and has to undo as a single action.
@@ -17,6 +17,7 @@
#include "slic3r/GUI/Jobs/Worker.hpp"
#include <memory>
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TriangleMesh.hpp"
namespace Slic3r::GUI {
@@ -52,13 +53,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
indexed_triangle_set mesh = m_input.base_mesh;
TextureDisplacementFacetsData masks = m_input.facets_data;
std::vector<Vec2f> uv = m_input.model_uv;
// The default pipeline can only move vertices the mesh already has, so its recipe starts with the
// preparation - which is where "first it remeshes, then it refines" actually happens. The
// experimental pipeline refines as part of the bake and has nothing to prepare.
if (m_input.run_prepare && !m_input.options.pipeline_v2) {
const TextureDisplacementPrepareResult prepared =
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params,
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, uv, m_input.prepare_params,
m_input.color,
// Preparation is roughly half the run; the bake
// takes the progress bar from there.
@@ -77,12 +79,14 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
if (!prepared.mesh.indices.empty()) {
mesh = prepared.mesh;
masks = prepared.masks;
uv = prepared.uv; // resampled onto the prepared mesh, as the prepare commit stores it
}
}
build_texture_displacement(mesh, m_input.layers, masks, m_input.options,
[&report](int pct) { return report(50 + pct / 2); },
/* color */ nullptr, m_input.volume_to_world, &recorder);
/* color */ nullptr, m_input.volume_to_world, &recorder, /* stats */ nullptr,
uv.empty() ? nullptr : &uv);
m_stages = recorder.take();
ctl.update_status(100, status); // always finish at 100: this is what closes the notification
@@ -115,6 +119,8 @@ void queue_texture_displacement_debug(const ModelVolume &volume, const TextureCo
input.check_topology = check_topology;
input.color = color;
input.volume_to_world = texture_displacement_volume_to_world(volume);
if (volume.has_texture_uv())
input.model_uv = volume.texture_uv;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();
@@ -47,6 +47,9 @@ struct TextureDisplacementDebugInput
// Only the quantizer is used, and only by the refinement's colour criterion - the debug run
// produces no colour of its own.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), so the recipe captured here is the one a
// ModelUV layer actually bakes through. Empty when the volume has none.
std::vector<Vec2f> model_uv;
// The edge scan behind the open / non-manifold counts is a sort over every half-edge, which on a
// multi-million triangle stage costs more than the stage did. On by default because a stage that
@@ -46,7 +46,7 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl)
// idle loop.
int last_reported = 1;
m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers,
m_input.params, m_input.color,
m_input.model_uv, m_input.params, m_input.color,
[&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled())
return false;
@@ -106,6 +106,10 @@ void TextureDisplacementPrepareJob::finalize(bool canceled, std::exception_ptr &
volume->restore_painting(saved_painting);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
volume->texture_displacement_facet(i).set_data(std::move(m_result.masks[size_t(i)]));
// The model's own UV channel, resampled onto the new mesh by the run. Also put back after
// restore_painting(), which resets it along with the extra facets for the same reason: it is
// indexed by triangle and the triangles have changed.
volume->texture_uv = std::move(m_result.uv);
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
@@ -9,6 +9,7 @@
#include "Job.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TextureDisplacement.hpp"
namespace Slic3r {
@@ -47,6 +48,10 @@ struct TextureDisplacementPrepareInput
// skips the colour criterion entirely. Only the *quantizer* is used here: refinement follows
// perceived colour, never the interleaving that realises a mix.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), empty when it has none. prepare_mesh()
// resamples it onto the mesh it produces, and the commit puts it back - like the masks, it is
// indexed by triangle and so cannot survive the stages on its own.
std::vector<Vec2f> model_uv;
// The undo step the commit opens. Standard mode's Bake names it after the bake, because the
// displacement job that follows commits into this same step rather than pushing its own.
std::string snapshot_name;
@@ -48,7 +48,8 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
(!m_current_generation ||
m_current_generation->load() == m_generation);
},
color, m_input.volume_to_world);
color, m_input.volume_to_world, nullptr, nullptr,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv);
}
void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr)
@@ -29,6 +29,9 @@ struct TextureDisplacementPreviewInput
// Empty unless a layer is colouring, in which case the preview reports the palette entry per
// triangle alongside the mesh, so the Normal view shows the colours the bake will produce.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), so a ModelUV layer previews through the
// same layout it will bake through. Empty when the volume has none.
std::vector<Vec2f> model_uv;
};
// A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per
+77 -30
View File
@@ -27,6 +27,7 @@
#include <wx/mstream.h>
#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<unsigned char> &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<unsigned char> &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<unsigned char> 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<unsigned char> &bytes)
std::vector<TextureLibraryEntry> 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<TextureLibraryEntry> write_user_texture(const std::vector<unsigned char> &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<const char *>(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<TextureLibraryEntry> 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<TextureLibraryEntry> 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<const char *>(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<std::vector<unsigned char>> load_texture_image_data(const std::string &path, std::string &error)
@@ -382,4 +412,21 @@ std::shared_ptr<std::vector<unsigned char>> load_texture_image_data(const std::s
return std::make_shared<std::vector<unsigned char>>(std::move(converted));
}
std::optional<TextureLibraryEntry> save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem,
std::string &error)
{
std::vector<unsigned char> scratch;
const wxImage wx = image_from_decoded(image, scratch);
if (!wx.IsOk()) {
error = _u8L("The selected image is empty.");
return std::nullopt;
}
std::vector<unsigned char> 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
+10
View File
@@ -6,6 +6,8 @@
#include <string>
#include <vector>
#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<TextureLibraryEntry> 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<std::vector<unsigned char>> 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<TextureLibraryEntry> save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem,
std::string &error);
} // namespace Slic3r::GUI
#endif // slic3r_TextureLibrary_hpp_
+17 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff