diff --git a/Ink Canvas/MainWindow.xaml b/Ink Canvas/MainWindow.xaml index 10158954..05fd1aba 100644 --- a/Ink Canvas/MainWindow.xaml +++ b/Ink Canvas/MainWindow.xaml @@ -1,4 +1,4 @@ - + + + + + + + + + + + + + + + /// 墨迹预测(书写中速度外推预览线)与笔锋相关输入状态,思路参考智绘教 Inkeys 的 RTS 速度与低延迟手感。 + /// + public partial class MainWindow + { + private bool _inkPredictionStrokeActive; + private bool _inkPredictionHasSample; + private bool _inkPredictionHasVelocity; + private Point _inkPredictionLastPos; + private int _inkPredictionLastTime; + private double _inkPredictionVx; + private double _inkPredictionVy; + + private void ResetInkPredictionState() + { + _inkPredictionStrokeActive = false; + _inkPredictionHasSample = false; + _inkPredictionHasVelocity = false; + ClearInkPredictionOverlay(); + } + + private void ClearInkPredictionOverlay() + { + try + { + if (InkPredictionPolyline == null) return; + InkPredictionPolyline.Visibility = Visibility.Collapsed; + InkPredictionPolyline.Points.Clear(); + } + catch + { + // ignore + } + } + + private void BeginInkPredictionStrokeIfNeeded() + { + try + { + if (Settings?.Canvas == null || !Settings.Canvas.EnableInkStrokePrediction) + { + _inkPredictionStrokeActive = false; + return; + } + + _inkPredictionStrokeActive = inkCanvas != null + && inkCanvas.EditingMode == InkCanvasEditingMode.Ink + && penType != 1 + && !_isBoardBrushMode; + _inkPredictionHasSample = false; + _inkPredictionHasVelocity = false; + ClearInkPredictionOverlay(); + } + catch + { + _inkPredictionStrokeActive = false; + } + } + + private void EndInkPredictionStroke() + { + _inkPredictionStrokeActive = false; + _inkPredictionHasSample = false; + _inkPredictionHasVelocity = false; + ClearInkPredictionOverlay(); + } + + private void inkCanvas_PreviewStylusMove(object sender, StylusEventArgs e) + { + try + { + if (Settings?.Canvas == null || !Settings.Canvas.EnableInkStrokePrediction) return; + if (inkCanvas == null || InkPredictionPolyline == null) return; + if (!_inkPredictionStrokeActive || penType == 1) return; + if (inkCanvas.EditingMode != InkCanvasEditingMode.Ink) return; + + if (e.InAir) + { + ClearInkPredictionOverlay(); + return; + } + + var pos = e.GetPosition(inkCanvas); + UpdateInkPredictionCore(pos, e.Timestamp); + } + catch + { + // ignore + } + } + + private void inkCanvas_LostStylusCapture(object sender, StylusEventArgs e) + { + EndInkPredictionStroke(); + } + + private void inkCanvas_PreviewMouseMoveForPrediction(object sender, MouseEventArgs e) + { + try + { + if (Settings?.Canvas == null || !Settings.Canvas.EnableInkStrokePrediction) return; + if (inkCanvas == null || InkPredictionPolyline == null) return; + if (!_inkPredictionStrokeActive || penType == 1) return; + if (inkCanvas.EditingMode != InkCanvasEditingMode.Ink) return; + if (e.LeftButton != MouseButtonState.Pressed) return; + if (e.StylusDevice != null) return; + + var pos = e.GetPosition(inkCanvas); + UpdateInkPredictionCore(pos, Environment.TickCount & int.MaxValue); + } + catch + { + // ignore + } + } + + private void UpdateInkPredictionCore(Point pos, int timestamp) + { + if (InkPredictionPolyline == null || Settings?.Canvas == null) return; + + if (!_inkPredictionHasSample) + { + _inkPredictionLastPos = pos; + _inkPredictionLastTime = timestamp; + _inkPredictionHasSample = true; + return; + } + + double dtMs = timestamp - _inkPredictionLastTime; + if (dtMs <= 0 || dtMs > 120) dtMs = 16; + + double vx = (pos.X - _inkPredictionLastPos.X) / dtMs * 1000.0; + double vy = (pos.Y - _inkPredictionLastPos.Y) / dtMs * 1000.0; + + const double velocitySmooth = 0.62; + if (!_inkPredictionHasVelocity) + { + _inkPredictionVx = vx; + _inkPredictionVy = vy; + _inkPredictionHasVelocity = true; + } + else + { + _inkPredictionVx = velocitySmooth * _inkPredictionVx + (1.0 - velocitySmooth) * vx; + _inkPredictionVy = velocitySmooth * _inkPredictionVy + (1.0 - velocitySmooth) * vy; + } + + const double leadMs = 24.0; + double predX = pos.X + _inkPredictionVx * (leadMs / 1000.0); + double predY = pos.Y + _inkPredictionVy * (leadMs / 1000.0); + + double maxDist = Math.Max(4.0, Settings.Canvas.InkStrokePredictionMaxDistance); + double dx = predX - pos.X; + double dy = predY - pos.Y; + double len = Math.Sqrt(dx * dx + dy * dy); + if (len > maxDist && len > 1e-6) + { + double s = maxDist / len; + predX = pos.X + dx * s; + predY = pos.Y + dy * s; + } + + _inkPredictionLastPos = pos; + _inkPredictionLastTime = timestamp; + + var da = inkCanvas.DefaultDrawingAttributes; + var c = da.Color; + InkPredictionPolyline.Stroke = new SolidColorBrush(Color.FromArgb(110, c.R, c.G, c.B)); + InkPredictionPolyline.StrokeThickness = Math.Max(1.0, da.Width * 0.42); + + InkPredictionPolyline.Points.Clear(); + InkPredictionPolyline.Points.Add(pos); + InkPredictionPolyline.Points.Add(new Point(predX, predY)); + InkPredictionPolyline.Visibility = Visibility.Visible; + } + } +} diff --git a/Ink Canvas/MainWindow_cs/MW_Settings.cs b/Ink Canvas/MainWindow_cs/MW_Settings.cs index 96f91efc..89ddd4ab 100644 --- a/Ink Canvas/MainWindow_cs/MW_Settings.cs +++ b/Ink Canvas/MainWindow_cs/MW_Settings.cs @@ -2700,6 +2700,24 @@ namespace Ink_Canvas SaveSettingsToFile(); } + private void ToggleSwitchEnableInkStrokePrediction_Toggled(object sender, RoutedEventArgs e) + { + if (!isLoaded) return; + + Settings.Canvas.EnableInkStrokePrediction = ToggleSwitchEnableInkStrokePrediction.IsOn; + if (!Settings.Canvas.EnableInkStrokePrediction) + EndInkPredictionStroke(); + SaveSettingsToFile(); + } + + private void ToggleSwitchEnableVelocityBrushTip_Toggled(object sender, RoutedEventArgs e) + { + if (!isLoaded) return; + + Settings.Canvas.EnableVelocityBrushTip = ToggleSwitchEnableVelocityBrushTip.IsOn; + SaveSettingsToFile(); + } + private void ToggleSwitchAutoStraightenLine_Toggled(object sender, RoutedEventArgs e) { if (!isLoaded) return; diff --git a/Ink Canvas/MainWindow_cs/MW_SettingsToLoad.cs b/Ink Canvas/MainWindow_cs/MW_SettingsToLoad.cs index 23000346..6f7c1e83 100644 --- a/Ink Canvas/MainWindow_cs/MW_SettingsToLoad.cs +++ b/Ink Canvas/MainWindow_cs/MW_SettingsToLoad.cs @@ -941,6 +941,11 @@ namespace Ink_Canvas // 初始化直线端点吸附相关设置 ToggleSwitchLineEndpointSnapping.IsOn = Settings.Canvas.LineEndpointSnapping; ToggleSwitchCompressPicturesUploaded.IsOn = Settings.Canvas.IsCompressPicturesUploaded; + + if (ToggleSwitchEnableInkStrokePrediction != null) + ToggleSwitchEnableInkStrokePrediction.IsOn = Settings.Canvas.EnableInkStrokePrediction; + if (ToggleSwitchEnableVelocityBrushTip != null) + ToggleSwitchEnableVelocityBrushTip.IsOn = Settings.Canvas.EnableVelocityBrushTip; } else { diff --git a/Ink Canvas/MainWindow_cs/MW_ShapeDrawing.cs b/Ink Canvas/MainWindow_cs/MW_ShapeDrawing.cs index 1fdc9dcf..219aec66 100644 --- a/Ink Canvas/MainWindow_cs/MW_ShapeDrawing.cs +++ b/Ink Canvas/MainWindow_cs/MW_ShapeDrawing.cs @@ -2565,6 +2565,7 @@ namespace Ink_Canvas /// private void inkCanvas_MouseUp(object sender, MouseButtonEventArgs e) { + EndInkPredictionStroke(); HandleEraserOperationEnded(); // 橡皮擦自动切换回批注模式:松手后启动/重置计时 inkCanvas.ReleaseMouseCapture(); ViewboxFloatingBar.IsHitTestVisible = true; diff --git a/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs b/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs index a52fb30e..eff00468 100644 --- a/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs +++ b/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs @@ -204,6 +204,7 @@ namespace Ink_Canvas try { inkCanvas.Opacity = 1; + var touchPressureSimulationApplied = false; if (Settings.Canvas.DisablePressure) { @@ -256,6 +257,7 @@ namespace Ink_Canvas stylusPoints.Add(point); } + touchPressureSimulationApplied = true; e.Stroke.StylusPoints = stylusPoints; } catch (Exception ex) { System.Diagnostics.Debug.WriteLine(ex); } @@ -304,6 +306,7 @@ namespace Ink_Canvas } } + touchPressureSimulationApplied = true; e.Stroke.StylusPoints = stylusPoints; } catch (Exception ex) { System.Diagnostics.Debug.WriteLine(ex); } @@ -312,6 +315,18 @@ namespace Ink_Canvas } } + if (Settings.Canvas.EnableVelocityBrushTip + && !touchPressureSimulationApplied + && !Settings.Canvas.DisablePressure + && penType != 1 + && e.Stroke?.DrawingAttributes != null + && !e.Stroke.DrawingAttributes.IsHighlighter + && !e.Stroke.DrawingAttributes.IgnorePressure + && e.Stroke.StylusPoints.Count >= 3) + { + ApplyVelocityBrushTipFromSpeed(e.Stroke); + } + // Apply line straightening and endpoint snapping if ink-to-shape is enabled if (Settings.InkToShape.IsInkToShapeEnabled) @@ -2033,6 +2048,56 @@ namespace Ink_Canvas / 20; } + /// + /// 将沿线速度映射为压感并与硬件压感混合,快写略细、慢写略粗;与 Inkeys 中 RTSSpeed 驱动的笔锋类似,在落笔后统一施加。 + /// + private void ApplyVelocityBrushTipFromSpeed(Stroke stroke) + { + try + { + var mix = Settings.Canvas.VelocityBrushTipMix; + if (mix <= 0 || stroke == null) return; + if (mix > 1) mix = 1; + + var pts = stroke.StylusPoints; + if (pts.Count < 3) return; + + var n = pts.Count - 1; + var stylusPoints = new StylusPointCollection(); + + for (var i = 0; i <= n; i++) + { + var speed = GetPointSpeed( + pts[Math.Max(i - 1, 0)].ToPoint(), + pts[i].ToPoint(), + pts[Math.Min(i + 1, n)].ToPoint()); + + float speedPressure; + if (speed >= 0.25) + speedPressure = (float)(0.5 - 0.3 * (Math.Min(speed, 1.5) - 0.3) / 1.2); + else if (speed >= 0.05) + speedPressure = 0.5f; + else + speedPressure = (float)(0.5 + 0.4 * (0.05 - speed) / 0.05); + + speedPressure = (float)Math.Max(0.08, Math.Min(1.0, speedPressure)); + + var basePf = pts[i].PressureFactor; + var blended = (float)((1.0 - mix) * basePf + mix * speedPressure); + blended = (float)Math.Max(0.08, Math.Min(1.0, blended)); + + var p = new StylusPoint(pts[i].X, pts[i].Y, blended); + stylusPoints.Add(p); + } + + stroke.StylusPoints = stylusPoints; + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + } + public Point[] FixPointsDirection(Point p1, Point p2) { double deltaY = Math.Abs(p1.Y - p2.Y); diff --git a/Ink Canvas/Properties/Strings.enUS.xml b/Ink Canvas/Properties/Strings.enUS.xml index 6ca0619c..0e3d8b09 100644 --- a/Ink Canvas/Properties/Strings.enUS.xml +++ b/Ink Canvas/Properties/Strings.enUS.xml @@ -374,6 +374,10 @@ # When on, touch screens that support pressure will show pressure; for devices not recognized by the system. Ignore pressure # When on, all strokes use uniform thickness; mutually exclusive with pressure-sensitive touch. + Ink stroke prediction (latency hint) + # While inking, draws a short semi-transparent segment ahead along motion to reduce perceived latency (similar idea to Inkeys). + Velocity brush tip (pressure blend) + # For pen pressure, blends speed with hardware pressure: faster strokes thinner, slower thicker. Touch simulated pressure is unchanged. Eraser size Very small Small diff --git a/Ink Canvas/Properties/Strings.resx b/Ink Canvas/Properties/Strings.resx index fb531d30..9add8b86 100644 --- a/Ink Canvas/Properties/Strings.resx +++ b/Ink Canvas/Properties/Strings.resx @@ -389,6 +389,10 @@ # 开启后,触屏设备也将支持压感效果,适用于部分支持压感但无法被系统识别的触屏设备。 屏蔽压感 # 开启后,将忽略所有设备的压感信息,使所有笔画具有统一的粗细。与压感触屏模式互斥。 + 墨迹预测(低延迟预览线) + # 书写时沿运动方向外推一小段半透明预览线,减轻显示与采样延迟;思路类似智绘教 Inkeys 的流畅笔迹。 + 速度笔锋(压感混合) + # 对触控笔等真实压感,按运笔速度与硬件压感混合:快画略细、慢画略粗;触屏模拟压感路径仍单独处理,避免重复叠加。 橡皮大小 很小 较小 diff --git a/Ink Canvas/Resources/Settings.cs b/Ink Canvas/Resources/Settings.cs index 334263ce..78d02ff9 100644 --- a/Ink Canvas/Resources/Settings.cs +++ b/Ink Canvas/Resources/Settings.cs @@ -146,6 +146,30 @@ namespace Ink_Canvas [JsonProperty("eraserAutoSwitchBackDelaySeconds")] public int EraserAutoSwitchBackDelaySeconds { get; set; } = 10; // 默认10秒 + /// + /// 书写时根据速度外推一小段预览线,补偿显示/采样延迟(类似智绘教 Inkeys 的低延迟手感)。 + /// + [JsonProperty("enableInkStrokePrediction")] + public bool EnableInkStrokePrediction { get; set; } = true; + + /// + /// 预测线段最大长度(与设备无关的逻辑像素/DIP),过大易飘,过小不明显。 + /// + [JsonProperty("inkStrokePredictionMaxDistance")] + public double InkStrokePredictionMaxDistance { get; set; } = 18.0; + + /// + /// 用笔等真实压感设备时,将速度与硬件压感按 混合,使快画偏细、慢画偏粗(参考 Inkeys RTSSpeed 思路)。 + /// + [JsonProperty("enableVelocityBrushTip")] + public bool EnableVelocityBrushTip { get; set; } = true; + + /// + /// 速度笔锋混合比例 0–1,越大速度对粗细影响越明显。 + /// + [JsonProperty("velocityBrushTipMix")] + public double VelocityBrushTipMix { get; set; } = 0.22; + } public enum OptionalOperation