mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Merge PR #8354: Keep the CPU canvas sharp at 100% zoom
This commit is contained in:
@@ -89,6 +89,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|
||||
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
|
||||
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
|
||||
| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 |
|
||||
| `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` |
|
||||
|
||||
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
|
||||
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
|
||||
|
||||
@@ -1050,14 +1050,52 @@ impl ViewXform {
|
||||
let v = app.ui.views.get(app.session.active_index()?)?;
|
||||
let (zoom, center) =
|
||||
if v.zoom_animating && app.session.prefs().general.animated_zoom { v.display_camera.unwrap_or((v.zoom, v.center)) } else { (v.zoom, v.center) };
|
||||
Some(Self {
|
||||
rect: crate::rulers::content_rect(app, app.last_canvas_rect),
|
||||
zoom: zoom / app.canvas_ppp(),
|
||||
center,
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
rotation: v.rotation,
|
||||
aspect: app.ui.view.display_aspect(),
|
||||
})
|
||||
Some(
|
||||
Self {
|
||||
rect: crate::rulers::content_rect(app, app.last_canvas_rect),
|
||||
zoom: zoom / app.canvas_ppp(),
|
||||
center,
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
rotation: v.rotation,
|
||||
aspect: app.ui.view.display_aspect(),
|
||||
}
|
||||
.pixel_aligned_for(app, app.canvas_ppp()),
|
||||
)
|
||||
}
|
||||
|
||||
fn pixel_aligned_for(self, app: &PhotocraftApp, ppp: f32) -> Self {
|
||||
// Default Crop counter-moves the frame and camera with fractional pointer deltas.
|
||||
// Preserve that continuous mapping so its fixed frame does not wobble during a drag.
|
||||
if crate::crop_mode::moves_image(app) { self } else { self.pixel_aligned(ppp) }
|
||||
}
|
||||
|
||||
/// At integer physical magnifications, texel edges must land on display pixel edges.
|
||||
/// Keep this derived camera shared by images, overlays and input without changing the saved pan.
|
||||
fn pixel_aligned(mut self, ppp: f32) -> Self {
|
||||
if !ppp.is_finite() || ppp <= 0.0 || !self.zoom.is_finite() || self.zoom <= 0.0 || !self.rotation.is_finite() || self.rotation.abs() >= 1e-8 {
|
||||
return self;
|
||||
}
|
||||
let scale = vec2(self.zoom_x(), self.zoom) * ppp;
|
||||
if !scale.is_finite()
|
||||
|| scale.x.round() < 1.0
|
||||
|| scale.y.round() < 1.0
|
||||
|| (scale.x - scale.x.round()).abs() > 1e-5
|
||||
|| (scale.y - scale.y.round()).abs() > 1e-5
|
||||
{
|
||||
return self;
|
||||
}
|
||||
let origin = self.to_screen(0.0, 0.0);
|
||||
let physical = origin * ppp;
|
||||
if !physical.is_finite() {
|
||||
return self;
|
||||
}
|
||||
let aligned = pos2(physical.x.round(), physical.y.round()) / ppp;
|
||||
let offset = self.unmap_vec((aligned - origin) / self.zoom);
|
||||
let center = [self.center[0] - offset.x, self.center[1] - offset.y];
|
||||
if center.iter().all(|v| v.is_finite()) {
|
||||
self.center = center;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn sincos(self) -> (f32, f32) {
|
||||
@@ -3476,7 +3514,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
if view.zoom_animating {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let xf = ViewXform { zoom: display_view.zoom / ppp, center: display_view.center, ..target_xf };
|
||||
let xf = ViewXform { zoom: display_view.zoom / ppp, center: display_view.center, ..target_xf }.pixel_aligned_for(app, ppp);
|
||||
let pixel_grid = app.ui.view.shows(app.ui.view.show.pixel_grid);
|
||||
let response = ui.allocate_rect(rect, Sense::click_and_drag());
|
||||
let stroke_dismissal = primary && crate::shape_stroke_ui::dismiss_from_canvas(app, &response);
|
||||
@@ -3530,8 +3568,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
zoom: view.zoom * k as f32 / ppp,
|
||||
// A size-changing preview grows around the old image center; keep the view's pan.
|
||||
center: [
|
||||
view.center[0] / k as f32 + (preview_size[0] as f32 - original_size[0] as f32) / 2.0,
|
||||
view.center[1] / k as f32 + (preview_size[1] as f32 - original_size[1] as f32) / 2.0,
|
||||
xf.center[0] / k as f32 + (preview_size[0] as f32 - original_size[0] as f32) / 2.0,
|
||||
xf.center[1] / k as f32 + (preview_size[1] as f32 - original_size[1] as f32) / 2.0,
|
||||
],
|
||||
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
|
||||
shadow: {
|
||||
@@ -3554,7 +3592,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
doc: doc.id.0,
|
||||
doc_size: [doc.size.width, doc.size.height],
|
||||
zoom: point_zoom,
|
||||
center: view.center,
|
||||
center: xf.center,
|
||||
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
|
||||
shadow: {
|
||||
let t = crate::theme::Tokens::get(&ctx);
|
||||
@@ -4414,7 +4452,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
display_view.center = view.center;
|
||||
}
|
||||
display_view.rotation = view.rotation;
|
||||
ViewXform { center: display_view.center, rotation: display_view.rotation, ..xf }
|
||||
ViewXform { center: display_view.center, rotation: display_view.rotation, ..xf }.pixel_aligned_for(app, ppp)
|
||||
} else {
|
||||
xf
|
||||
};
|
||||
@@ -10059,6 +10097,9 @@ mod display_memo_tests;
|
||||
#[path = "canvas/blend_viewport_tests.rs"]
|
||||
mod blend_viewport_regression;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "canvas/pixel_alignment_tests.rs"]
|
||||
mod pixel_alignment_tests;
|
||||
#[cfg(test)]
|
||||
mod lasso_preview_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use super::*;
|
||||
|
||||
fn xform(ppp: f32, physical_size: [f32; 2]) -> ViewXform {
|
||||
ViewXform {
|
||||
rect: Rect::from_min_size(pos2(40.0 / ppp, 100.0 / ppp), vec2(physical_size[0] / ppp, physical_size[1] / ppp)),
|
||||
zoom: 1.0 / ppp,
|
||||
center: [1500.0, 1000.0],
|
||||
flip: false,
|
||||
rotation: 0.0,
|
||||
aspect: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_pixel_edges(xf: ViewXform, ppp: f32) {
|
||||
for [x, y] in [[0.0, 0.0], [1.0, 1.0], [17.0, 29.0], [1500.0, 1000.0]] {
|
||||
let p = xf.to_screen(x, y) * ppp;
|
||||
assert!((p.x - p.x.round()).abs() < 0.001, "x edge: {p:?}, ppp {ppp}");
|
||||
assert!((p.y - p.y.round()).abs() < 0.001, "y edge: {p:?}, ppp {ppp}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_pixels_align_texel_edges_in_odd_viewports_at_every_display_scale() {
|
||||
for ppp in [1.0, 1.25, 1.5, 2.0] {
|
||||
for size in [[523.0, 470.0], [524.0, 471.0], [523.0, 471.0]] {
|
||||
let before = xform(ppp, size);
|
||||
let aligned = before.pixel_aligned(ppp);
|
||||
assert_pixel_edges(aligned, ppp);
|
||||
let delta = (aligned.to_screen(0.0, 0.0) - before.to_screen(0.0, 0.0)) * ppp;
|
||||
assert!(delta.x.abs() <= 0.501 && delta.y.abs() <= 0.501);
|
||||
let again = aligned.pixel_aligned(ppp);
|
||||
assert!((again.to_screen(0.0, 0.0) - aligned.to_screen(0.0, 0.0)).length() < 0.001);
|
||||
let back = aligned.to_doc(aligned.to_screen(123.25, 45.75));
|
||||
assert!((back[0] - 123.25).abs() < 0.001 && (back[1] - 45.75).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_magnification_aligns_flipped_and_aspect_corrected_views() {
|
||||
for flip in [false, true] {
|
||||
let before = ViewXform {
|
||||
rect: Rect::from_min_size(pos2(-20.25, 30.25), vec2(523.0, 471.0)),
|
||||
zoom: 2.0,
|
||||
center: [1500.125, 999.75],
|
||||
flip,
|
||||
aspect: 1.5,
|
||||
..xform(1.0, [523.0, 471.0])
|
||||
};
|
||||
assert_pixel_edges(before.pixel_aligned(1.0), 1.0);
|
||||
}
|
||||
let even = xform(1.0, [524.0, 470.0]);
|
||||
assert_eq!(even.pixel_aligned(1.0).center, even.center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_zoom_rotation_and_invalid_scales_preserve_the_camera() {
|
||||
let original = xform(1.0, [523.0, 471.0]);
|
||||
for zoom in [0.25, 0.5, 0.75, 1.25, 1.5, 2.5, 0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
let xf = ViewXform { zoom, ..original };
|
||||
assert_eq!(xf.pixel_aligned(1.0).center, xf.center);
|
||||
}
|
||||
for rotation in [0.2, 30.0, 90.0, f32::NAN] {
|
||||
let xf = ViewXform { rotation, ..original };
|
||||
assert_eq!(xf.pixel_aligned(1.0).center, xf.center);
|
||||
}
|
||||
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(original.pixel_aligned(ppp).center, original.center);
|
||||
}
|
||||
let aspect = ViewXform { aspect: 1.2, ..original };
|
||||
assert_eq!(aspect.pixel_aligned(1.0).center, aspect.center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_transform_uses_the_same_alignment_without_changing_saved_pan() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", serde_json::json!({"width": 3000, "height": 2000})).unwrap();
|
||||
app.sync_views();
|
||||
for ppp in [1.0, 1.25, 1.5, 2.0] {
|
||||
app.ppp = ppp;
|
||||
app.last_canvas_rect = xform(ppp, [523.0, 471.0]).rect;
|
||||
app.ui.views[0].center = [1500.0, 1000.0];
|
||||
app.ui.views[0].zoom = 1.0;
|
||||
let before = app.ui.views[0].center;
|
||||
let active = ViewXform::active(&app).unwrap();
|
||||
assert_pixel_edges(active, ppp);
|
||||
assert_eq!(app.ui.views[0].center, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_canvas_paints_its_image_on_physical_pixel_edges() {
|
||||
for ppp in [1.0, 1.25, 2.0] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", serde_json::json!({"width": 64, "height": 64, "background": "white"})).unwrap();
|
||||
app.sync_views();
|
||||
app.ppp = ppp;
|
||||
app.ui.views[0].zoom = 1.0;
|
||||
app.ui.views[0].center = [32.0, 32.0];
|
||||
app.ui.views[0].fit_pending = false;
|
||||
app.ui.views[0].doc_size = [64, 64];
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_pixels_per_point(ppp);
|
||||
let tex = ensure_texture(&mut app, &ctx, 0, None).unwrap().0;
|
||||
let view = app.ui.views[0].clone();
|
||||
let saved_center = view.center;
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(900.0, 600.0))), ..Default::default() }, |ui| {
|
||||
canvas_view(&mut app, ui, 0, xform(ppp, [523.0, 471.0]).rect, view.clone(), false);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mesh = out
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
egui::Shape::Mesh(mesh) if mesh.texture_id == tex => Some(mesh),
|
||||
_ => None,
|
||||
})
|
||||
.expect("CPU document image quad");
|
||||
assert_eq!(mesh.vertices.len(), 4);
|
||||
for vertex in &mesh.vertices {
|
||||
let p = vertex.pos * ppp;
|
||||
assert!((p.x - p.x.round()).abs() < 0.001 && (p.y - p.y.round()).abs() < 0.001, "ppp {ppp}: {p:?}");
|
||||
}
|
||||
assert_eq!(app.ui.views[0].center, saved_center);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_movement_keeps_its_continuous_camera_mapping() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.tool = Tool::Crop;
|
||||
let xf = xform(1.0, [523.0, 471.0]);
|
||||
assert_eq!(xf.pixel_aligned_for(&app, 1.0).center, xf.center);
|
||||
app.ui.tool_options.crop_shield.classic_mode = true;
|
||||
assert_pixel_edges(xf.pixel_aligned_for(&app, 1.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_active_transform_aligns_the_display_camera_and_preserves_target_pan() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", serde_json::json!({"width": 3000, "height": 2000})).unwrap();
|
||||
app.run("prefs.set", serde_json::json!({"values": {"general.animatedZoom": true}})).unwrap();
|
||||
app.sync_views();
|
||||
for ppp in [1.0, 1.25, 1.5, 2.0] {
|
||||
app.ppp = ppp;
|
||||
app.last_canvas_rect = xform(ppp, [523.0, 471.0]).rect;
|
||||
let v = &mut app.ui.views[0];
|
||||
v.zoom = 3.0;
|
||||
v.center = [1500.0, 1000.0];
|
||||
v.zoom_animating = true;
|
||||
v.display_camera = Some((1.0, [1490.25, 990.75]));
|
||||
let active = ViewXform::active(&app).unwrap();
|
||||
assert_eq!(active.zoom, 1.0 / ppp);
|
||||
assert_pixel_edges(active, ppp);
|
||||
assert!((active.center[0] - 1490.25).abs() <= 0.501);
|
||||
assert!((active.center[1] - 990.75).abs() <= 0.501);
|
||||
assert_eq!(app.ui.views[0].center, [1500.0, 1000.0]);
|
||||
assert_eq!(app.ui.views[0].display_camera, Some((1.0, [1490.25, 990.75])));
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,14 @@ mod tests {
|
||||
ViewXform::active(h.state()).unwrap()
|
||||
}
|
||||
|
||||
// Pixel alignment derives a presentation camera within half a display pixel of the
|
||||
// stored target; interrupting an animation must keep that same target and its zoom.
|
||||
fn assert_camera_center(h: &Harness<'static, PhotocraftApp>, target: &View) {
|
||||
let xf = view_xf(h);
|
||||
let delta = (xf.to_screen(target.center[0], target.center[1]) - xf.rect.center()) * h.ctx.pixels_per_point();
|
||||
assert!(delta.x.abs() <= 0.501 && delta.y.abs() <= 0.501, "camera differs by {delta:?} display pixels");
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool) {
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
h.step();
|
||||
@@ -391,7 +399,7 @@ mod tests {
|
||||
let target = &h.state().ui.views[0];
|
||||
assert!(!target.zoom_animating, "{command}");
|
||||
assert_eq!(view_xf(&h).zoom, target.zoom);
|
||||
assert_eq!(view_xf(&h).center, target.center);
|
||||
assert_camera_center(&h, target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +432,7 @@ mod tests {
|
||||
h.step();
|
||||
let target = &h.state().ui.views[0];
|
||||
assert_eq!(view_xf(&h).zoom, target.zoom, "gesture {gesture}");
|
||||
assert_eq!(view_xf(&h).center, target.center, "gesture {gesture}");
|
||||
assert_camera_center(&h, target);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,9 @@
|
||||
# CPU canvas pixel alignment (#8321)
|
||||
|
||||
Original 200 × 200 pixel crops at screen coordinates (200, 250)–(400, 450) from 900 × 600 offscreen renders at display scale 1. The scene is a white 1024 × 768 RGB document with 60% monochromatic Add Noise, viewed at Actual Pixels through the CPU canvas (`--safe-gpu`). No external artwork is used.
|
||||
|
||||
`before.png` shows the half-pixel horizontal offset. `after.png` shows the aligned canvas and is byte-for-byte equal to the GPU canvas crop. Their grayscale standard deviations are 17.4105 and 24.6821 respectively. The 901-pixel-wide control stays sharp.
|
||||
|
||||
The source document fits the offscreen WARP adapter texture limit, so reduced-texture sampling does not mask the pixel-alignment comparison. These captures are original PhotoCraft assets, licensed MIT OR Apache-2.0 (the license files are alongside them).
|
||||
|
||||
Default Crop keeps its continuous camera positioning so the crop frame stays fixed during fractional pointer drags. Normal canvas views and Classic Crop use pixel alignment at integral physical magnifications.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Reference in New Issue
Block a user