Files
2026-07-13 13:13:17 +08:00

786 lines
27 KiB
C#

#nullable enable
using System.Drawing;
using System.Drawing.Drawing2D;
using Lib.Utils;
using Svg;
using Svg.Pathing;
using Svg.Transforms;
using T3.Core.Utils;
// ReSharper disable MemberCanBePrivate.Global
// ReSharper disable TooWideLocalVariableScope
// ReSharper disable CheckNamespace
namespace Lib.point.io;
/**
* This operator is a compromise: Please also review the additional documentation
* on the wiki: https://github.com/tixl3d/tixl/wiki/SvgLineFonts
*/
[Guid("3d862455-6a7b-4bf6-a159-e4f7cdba6062")]
internal sealed class LineTextPoints : Instance<LineTextPoints>
{
[Output(Guid = "618e9151-cd91-4aa6-9d91-4bb51610cc8b")]
public readonly Slot<StructuredList?> ResultList = new();
[Output(Guid = "15140635-C51D-4674-8FCA-006EBA46496E")]
public readonly Slot<StructuredList?> StartIndicesAndCounts = new();
public LineTextPoints()
{
_svgResource = new Resource<SvgDocument>(FilePath, SvgLoader.TryLoad);
_svgResource.AddDependentSlots(ResultList);
ResultList.UpdateAction += Update;
_structuredPoints.TypedElements[_structuredPoints.NumElements - 1] = Point.Separator();
}
private void Update(EvaluationContext context)
{
if (!_svgResource.TryGetValue(context, out var svgDoc))
{
ResultList.Value = null;
return;
}
var lineFontKey = new LineFontDefinition(Key: FilePath.GetValue(context) ?? string.Empty,
ReduceCurveThreshold: ReduceCurveThreshold.GetValue(context),
CornerBalance: CornerWeightBalance.GetValue(context));
var fontNeedsUpdate = _lineFontKey != lineFontKey;
var needsUpdate = fontNeedsUpdate
|| Text.DirtyFlag.IsDirty
|| Position.DirtyFlag.IsDirty
|| Size.DirtyFlag.IsDirty
|| LineHeight.DirtyFlag.IsDirty
|| VerticalAlign.DirtyFlag.IsDirty
|| HorizontalAlign.DirtyFlag.IsDirty
|| Spacing.DirtyFlag.IsDirty;
if (!needsUpdate)
return;
_lineFont = LineFont.Create(svgDoc, _lineFontKey, lineFontKey, this);
_lineFontKey = lineFontKey;
var cursorPos = Vector3.Zero;
var text = Text.GetValue(context);
if (string.IsNullOrEmpty(text))
text = " ";
var horizontalAlign = HorizontalAlign.GetEnumValue<HorizontalAligns>(context);
var verticalAlign = VerticalAlign.GetEnumValue<VerticalAligns>(context);
var characterSpacing = Spacing.GetValue(context);
var lineHeightFactor = LineHeight.GetValue(context);
var verticalFlip = _lineFont.UnitsPerEm < 0 ? 1 : -1;
var cursorLineHeight = MathF.Abs(_lineFont.LineHeight) * verticalFlip * lineHeightFactor;
var color = Color.GetValue(context);
var lineWidth = LineWidth.GetValue(context);
var position = Position.GetValue(context);
var entitySetter = SetFx1To.GetEnumValue<Entities>(context);
if (_lineFont == null)
return;
float cursorX = 0;
float cursorY = 0;
var needComputeLineWidths = verticalAlign != VerticalAligns.Top || horizontalAlign != HorizontalAligns.Left;
var lineCountInText = 1;
if (verticalAlign == VerticalAligns.Middle || verticalAlign == VerticalAligns.Bottom)
{
foreach (var c in text)
{
if (c == '\n')
{
lineCountInText++;
}
}
}
switch (verticalAlign)
{
case VerticalAligns.Top:
cursorY -= _lineFont.LineHeight * verticalFlip;
break;
case VerticalAligns.Middle:
cursorY -= _lineFont.LineHeight * verticalFlip - (lineCountInText - 1) / 2f * cursorLineHeight;
break;
case VerticalAligns.Bottom:
cursorY -= _lineFont.LineHeight * verticalFlip - (lineCountInText - 1) * cursorLineHeight;
break;
}
_points.Clear();
_startIndicesAndCounts.Clear();
var lastCharForKerning = 0;
var size = Size.GetValue(context) / MathF.Abs(_lineFont.UnitsPerEm) * 2/1080f; // Scaling to match 1080p 72DPI pt font sizes
var horizontalLineOffset = 0f;
var needUpdateCurrentLineWidth = needComputeLineWidths;
var wordIndex = 0;
var lineIndex = 0;
for (var index = 0; index < text.Length; index++)
{
if (needUpdateCurrentLineWidth)
{
horizontalLineOffset = GetHorizontalLineOffset(text, index, horizontalAlign, characterSpacing);
needUpdateCurrentLineWidth = false;
}
var c = text[index];
if (c == '\n')
{
cursorY -= cursorLineHeight;
cursorX = 0;
lastCharForKerning = 0;
needUpdateCurrentLineWidth = true;
wordIndex++;
lineIndex++;
continue;
}
if (c == ' ')
{
wordIndex++;
}
var glyph = _lineFont?.GetGlyphForCharacter(c);
if (glyph == null)
{
lastCharForKerning = 0;
continue;
}
if (lastCharForKerning != 0)
{
int key = lastCharForKerning | c;
if (_lineFont!.KerningForPairs.TryGetValue(key, out var kerning2))
{
cursorX += kerning2;
}
}
var numPoints = glyph.Points.Length;
var entityFloat = entitySetter switch
{
Entities.None => 0,
Entities.Characters => index,
Entities.Words => wordIndex,
Entities.Lines => lineIndex,
_ => throw new ArgumentOutOfRangeException()
};
_startIndicesAndCounts.Add(new Int2( _points.Count, numPoints));
if (numPoints > 0)
{
for (var pointIndex = 0; pointIndex < numPoints; pointIndex++)
{
var p = glyph.Points[pointIndex];
var pointPos = p.Position;
p.Position = new Vector3(
(cursorX - glyph.VertOriginX + pointPos.X + horizontalLineOffset) * size,
(cursorY - glyph.VertOriginY + pointPos.Y) * size * verticalFlip,
0) + position;
p.Color = color;
p.Scale *= lineWidth;// keep NaN
p.F1 = entityFloat;
p.F2 = 1;
_points.Add(p);
}
}
cursorPos.X += glyph.AdvanceX;
cursorX += glyph.AdvanceX;
cursorX += characterSpacing * MathF.Abs(_lineFont?.UnitsPerEm ?? 0) * 0.01f;
lastCharForKerning = c;
}
var pointCount = _points.Count;
if (pointCount == 0)
{
ResultList.Value = null;
return;
}
// Copy to points
if (_structuredPoints.TypedElements.Length != pointCount)
_structuredPoints = new StructuredList<Point>(_points.Count);
for (int index = pointCount - 1; index >= 0; --index)
{
_structuredPoints.TypedElements[index] = _points[index];
}
// Copy indices
if (_structuredIndicesAndCounts.TypedElements.Length != _startIndicesAndCounts.Count)
_structuredIndicesAndCounts = new StructuredList<Int2>(_startIndicesAndCounts.Count);
for (int index = _startIndicesAndCounts.Count - 1; index >= 0; --index)
{
_structuredIndicesAndCounts.TypedElements[index] = _startIndicesAndCounts[index];
}
ResultList.Value = _structuredPoints;
StartIndicesAndCounts.Value = _structuredIndicesAndCounts;
}
private float GetHorizontalLineOffset(string text, int indexAtLineStart, HorizontalAligns horizontalAligns, float characterSpacing)
{
if (_lineFont == null)
return 0;
var widthSum = 0f;
for (var index = indexAtLineStart; index < text.Length; index++)
{
var c = text[index];
if (c == '\n')
{
break;
}
var glyph = _lineFont?.GetGlyphForCharacter(c);
if (glyph == null)
continue;
widthSum += glyph.AdvanceX + characterSpacing * MathF.Abs(_lineFont!.UnitsPerEm) * 0.01f;
}
if (horizontalAligns == HorizontalAligns.Center)
return -widthSum / 2;
if (horizontalAligns == HorizontalAligns.Right)
return -widthSum;
return 0;
}
private readonly List<Point> _points = new(1000);
private StructuredList<Point> _structuredPoints = new(1);
private readonly List<Int2> _startIndicesAndCounts = new(128);
private StructuredList<Int2> _structuredIndicesAndCounts = new(1);
public enum HorizontalAligns
{
Left,
Center,
Right,
}
public enum VerticalAligns
{
Top,
Middle,
Bottom,
}
[Input(Guid = "8EE43C95-869E-4390-A567-CB0BB9C31BDD")]
public readonly InputSlot<string> Text = new();
[Input(Guid = "1077857C-93FA-403A-BBD1-FD5B2B402AE6")]
public readonly InputSlot<Vector3> Position = new();
[Input(Guid = "FB14EABB-B3C8-437C-B083-EBC5E0A46F7B")]
public readonly InputSlot<float> Size = new();
[Input(Guid = "EB22073E-2348-4E76-A096-A4F6F80E2C01")]
public readonly InputSlot<float> LineHeight = new();
[Input(Guid = "EEF1217E-E719-4C32-9A8A-5E7ACBE1A185", MappedType = typeof(VerticalAligns))]
public readonly InputSlot<int> VerticalAlign = new();
[Input(Guid = "4C275222-D85C-4037-8161-536105A6C80F", MappedType = typeof(HorizontalAligns))]
public readonly InputSlot<int> HorizontalAlign = new();
[Input(Guid = "D9CD13D4-C6A3-42DE-9119-5A164CB12C41")]
public readonly InputSlot<float> Spacing = new();
[Input(Guid = "05ACBA8D-1DD1-4D6C-BEFF-E11E466F7253")]
public readonly InputSlot<string> FilePath = new();
[Input(Guid = "77B18D33-A41E-4676-A55C-0DF8E0290D05")]
public readonly InputSlot<float> ReduceCurveThreshold = new();
[Input(Guid = "96F345D9-2E38-4AF6-937D-EE14528727E4")]
public readonly InputSlot<float> CornerWeightBalance = new();
[Input(Guid = "D0B12D48-75A6-4DC5-A634-4D969EC20A29")]
public readonly InputSlot<Vector4> Color = new();
[Input(Guid = "72324D06-1A8E-44F1-A946-9B0E7987908E")]
public readonly InputSlot<float> LineWidth = new();
[Input(Guid = "585A1124-1505-49BB-8E42-9F3E9B51845C", MappedType = typeof(Entities))]
public readonly InputSlot<int> SetFx1To = new();
private enum Entities
{
None,
Characters,
Words,
Lines,
}
private LineFont? _lineFont;
private readonly Resource<SvgDocument> _svgResource;
private LineFontDefinition? _lineFontKey;
}
internal record struct LineFontDefinition(string Key, float ReduceCurveThreshold, float CornerBalance);
internal sealed class LineFont
{
private const int GlyphTableLength = 256;
private Glyph[] _glyphTable = []; // Lookup table for fast lookup
private readonly Dictionary<char, Glyph> _glyphsForCharacters = new(); // Fallback dictionary
public Glyph? GetGlyphForCharacter(char c)
{
var value = (int)c;
if (value < GlyphTableLength)
return _glyphTable[value];
Log.Debug($"need dictionary for {c}", this);
return _glyphsForCharacters.TryGetValue(c, out var glyph) ? glyph : null;
}
public void InitGlyphTable()
{
_glyphTable = new Glyph[GlyphTableLength];
foreach (var (c, glyph) in _glyphsForCharacters)
{
var value = (int)c;
if (c >= GlyphTableLength)
continue;
_glyphTable[value] = glyph;
}
}
// ReSharper disable once CollectionNeverUpdated.Global
public readonly Dictionary<int, float> KerningForPairs = new();
public float UnitsPerEm = 1000;
public float Ascent = 800;
public float Descent = -200;
public float CapHeight = 500;
public float XHeight = 300;
public float LineHeight = 1000; // identical with FontSize
public float ReduceCurveThreshold = 0.2f;
public float CornerWeightBalance = 0.5f;
public static LineFont Create(SvgDocument svgDoc, LineFontDefinition? previousKey, LineFontDefinition key, object user)
{
if (svgDoc == null)
throw new ArgumentNullException(nameof(svgDoc));
if (previousKey == key)
{
return _definitionsForFilePaths[previousKey.Value];
}
if (previousKey.HasValue && _users.TryGetValue(previousKey.Value, out var previousUsers))
{
previousUsers.Remove(user);
if (previousUsers.Count == 0)
{
_users.Remove(previousKey.Value);
_definitionsForFilePaths.Remove(previousKey.Value);
}
}
if (_definitionsForFilePaths.TryGetValue(key, out var definition))
{
if (_users.TryGetValue(key, out var users))
{
users.Add(user);
}
else
{
_users[key] = [user];
}
return definition;
}
var svgElementCollection = svgDoc.Children;
var newLineFont = new LineFont()
{
ReduceCurveThreshold = key.ReduceCurveThreshold,
CornerWeightBalance = key.CornerBalance,
};
ParseFontDefinitionsInElements(svgElementCollection, newLineFont);
newLineFont.InitGlyphTable();
_definitionsForFilePaths[key] = newLineFont;
return newLineFont;
}
private static void ParseFontDefinitionsInElements(SvgElementCollection svgElementCollection,
LineFont font)
{
foreach (var svgDocChild in svgElementCollection)
{
switch (svgDocChild)
{
case SvgFont svgFont:
CollectGlyphsFromSvgFont(svgFont, font);
break;
case SvgDefinitionList svgDefinitionList:
ParseFontDefinitionsInElements(svgDefinitionList.Children, font);
break;
}
}
}
private static void CollectGlyphsFromSvgFont(SvgFont svgFont, LineFont font)
{
var fontFace = svgFont.Children.SingleOrDefault(c => c is SvgFontFace);
if (fontFace is SvgFontFace svgFontFace)
{
font.Descent = svgFontFace.Descent;
font.Ascent = svgFontFace.Ascent;
font.XHeight = svgFontFace.XHeight;
font.UnitsPerEm = svgFontFace.UnitsPerEm;
}
font.LineHeight = svgFont.FontSize != 0 ? svgFont.FontSize : font.UnitsPerEm;
foreach (var svgFontChild in svgFont.Children)
{
if (svgFontChild is not SvgGlyph svgGlyph)
continue;
var points = GetPointsFromSvgGroup(font, svgGlyph);
// if (points.Length == 0)
// continue;
if (string.IsNullOrEmpty(svgGlyph.Unicode) || svgGlyph.Unicode.Length != 1)
{
Log.Warning("Skipping svg glyph with missing or invalid unicode:" + svgGlyph.GlyphName);
continue;
}
var uniCode = svgGlyph.Unicode[0];
var glyphValue = (int)uniCode;
// There are some exporters that automatically assign a "icon" range to the Id.
// If that's the case we fall back to the GlyphName.
if (glyphValue > 1000 && svgGlyph.GlyphName?.Length == 1)
{
uniCode = svgGlyph.GlyphName[0];
}
font._glyphsForCharacters[uniCode] = new Glyph
{
Points = points,
UniCode = "" + uniCode,
Name = svgGlyph.GlyphName!,
AdvanceX = svgGlyph.HorizAdvX,
VertOriginX = svgGlyph.VertOriginX,
VertOriginY = svgGlyph.VertOriginY,
BoundsMin = new Vector2(svgGlyph.Bounds.Left, svgGlyph.Bounds.Top),
BoundsMax = new Vector2(svgGlyph.Bounds.Right, svgGlyph.Bounds.Bottom),
};
}
}
private static Point[] GetPointsFromSvgGroup(LineFont font, SvgElement svgGlyph)
{
var svgElements = new List<SvgElement> { svgGlyph };
var pathElements = GraphicsPathEntry.CreateFromSvgElements(svgElements, true);
// Flatten and sum total point count including separators
_tempPoints.Clear();
foreach (var pathElement in pathElements)
{
try
{
pathElement.GraphicsPath?.Flatten(null, font.ReduceCurveThreshold);
_ = pathElement.GraphicsPath?.PathPoints.Length; // Access path points to see if result is valid.
}
catch (Exception e)
{
Log.Debug("Can't flatten element" + e.Message);
continue;
}
var path = pathElement.GraphicsPath;
if (path == null)
continue;
var pathPointCount = path.PathPoints.Length;
var loopStartIndex = _tempPoints.Count;
var lastPos = new Vector3(-9999f, 0f, 0f);
for (var pathPointIndex = 0; pathPointIndex < pathPointCount; pathPointIndex++)
{
var point = path.PathPoints[pathPointIndex];
var position = (new Vector3(point.X, 1 - point.Y, 0));
var length = (lastPos - position).LengthSquared();
lastPos = position;
var tooClose = length < 0.000001f;
if (tooClose)
{
continue;
}
_tempPoints.Add(new Point
{
Position = position,
F1 = 1,
Orientation = Quaternion.Identity,
Color = Vector4.One,
Scale = Vector3.One,
F2 = 1,
});
}
// Close loop?
if (pathElement.NeedsClosing)
{
_tempPoints.Add(_tempPoints[loopStartIndex]);
}
_tempPoints.Add(Point.Separator());
}
var points = _tempPoints.ToArray();
var flip = font.UnitsPerEm < 0 ? 1 : -1;
// Calc point orientation
{
var p0 = Vector3.Zero;
var p0Valid = false;
var p1 = Vector3.Zero;
var p1Valid = false;
var p2 = Vector3.Zero;
var p2Valid = false;
for (var pointIndex = 0; pointIndex <= points.Length + 2; pointIndex++)
{
p0Valid = pointIndex >= 0 && pointIndex < points.Length && !float.IsNaN(points[pointIndex].F1);
if (p0Valid)
p0 = points[pointIndex].Position;
if (p1Valid)
{
if (p0Valid && p2Valid)
{
var v1 = Vector3.Normalize(p0-p1) - Vector3.Normalize(p2-p1);
var v2 = Vector3.Normalize(p0 - p2);
var v = Vector3.Lerp(v1, v2, font.CornerWeightBalance);
var angle = -MathF.Atan2(v.X, v.Y);
points[pointIndex-1].Orientation= Quaternion.CreateFromAxisAngle(Vector3.UnitZ, angle * flip);
}
else if (p2Valid)
{
var v = p1 - p2;
var p =points[pointIndex-1];
var angle = -MathF.Atan2(v.X, v.Y) ;
points[pointIndex-1].Orientation= Quaternion.CreateFromAxisAngle(Vector3.UnitZ, angle * flip );
}
else if (p0Valid)
{
var v = p0 - p1;
var p =points[pointIndex-1];
var angle = -MathF.Atan2(v.X, v.Y) ;
points[pointIndex-1].Orientation= Quaternion.CreateFromAxisAngle(Vector3.UnitZ, angle * flip );
}
}
p2Valid = p1Valid;
p2 = p1;
p1Valid = p0Valid;
p1 = p0;
}
}
return points;
}
private static readonly Dictionary<LineFontDefinition, LineFont> _definitionsForFilePaths = new();
private static readonly Dictionary<LineFontDefinition, HashSet<object>> _users = new();
private static readonly List<Point> _tempPoints = new(100); // Reuse to avoid GC
internal sealed class Glyph
{
//public char Char;
public float AdvanceX = 10;
public Point[] Points = [];
public string UniCode = string.Empty;
public string Name = string.Empty;
public float VertOriginX;
public float VertOriginY;
public Vector2 BoundsMin;
public Vector2 BoundsMax;
}
}
internal sealed class GraphicsPathEntry
{
public GraphicsPath? GraphicsPath;
public bool NeedsClosing;
public static List<GraphicsPathEntry> CreateFromSvgElements(IEnumerable<SvgElement> nodes, bool importAsLines)
{
var paths = new List<GraphicsPathEntry>();
_svgRenderer ??= SvgRenderer.FromImage(new Bitmap(1, 1));
foreach (var node in nodes)
{
switch (node)
{
case SvgPath svgPath:
{
var fullPath = svgPath.Path(_svgRenderer);
SplitGraphicsPathIntoSubPaths(fullPath, paths);
break;
}
case SvgGlyph svgGlyph:
{
var fullPath = svgGlyph.Path(_svgRenderer);
SplitGraphicsPathIntoSubPaths(fullPath, paths);
break;
}
case SvgGroup:
break;
case SvgPathBasedElement element:
{
if (element is SvgRectangle { Transforms: not null } rect)
{
foreach (var t in rect.Transforms)
{
if (t is not SvgTranslate tr)
continue;
rect.X += tr.X;
rect.Y += tr.Y;
}
}
var needsClosing = element is SvgRectangle or SvgCircle or SvgEllipse;
var graphicsPath = element.Path(_svgRenderer);
paths.Add(new GraphicsPathEntry
{
GraphicsPath = graphicsPath,
NeedsClosing = needsClosing && importAsLines
});
break;
}
}
}
return paths;
}
/// <summary>
/// Splits a GraphicsPath into sub-paths at each StartPoint marker.
/// This avoids manual segment iteration with AddToPath, letting the SVG library
/// handle path construction internally via SvgPath.Path(renderer).
/// </summary>
private static void SplitGraphicsPathIntoSubPaths(GraphicsPath? fullPath, List<GraphicsPathEntry> paths)
{
if (fullPath == null || fullPath.PointCount == 0)
return;
var points = fullPath.PathPoints;
var types = fullPath.PathTypes;
GraphicsPath? currentSubPath = null;
var subPathStart = PointF.Empty;
for (var i = 0; i < points.Length; i++)
{
var pathType = types[i];
var pointType = (PathPointType)(pathType & 0x07);
var isStartPoint = pointType == PathPointType.Start;
var isCloseSubPath = (pathType & (byte)PathPointType.CloseSubpath) != 0;
if (isStartPoint)
{
if (currentSubPath != null && currentSubPath.PointCount > 0)
{
paths.Add(new GraphicsPathEntry
{
GraphicsPath = currentSubPath,
NeedsClosing = false
});
}
currentSubPath = new GraphicsPath();
subPathStart = points[i];
}
currentSubPath ??= new GraphicsPath();
if (pointType == PathPointType.Line)
{
var prev = i > 0 ? points[i - 1] : subPathStart;
currentSubPath.AddLine(prev, points[i]);
}
else if (pointType == PathPointType.Bezier3 && i + 2 < points.Length)
{
var prev = i > 0 ? points[i - 1] : subPathStart;
currentSubPath.AddBezier(prev, points[i], points[i + 1], points[i + 2]);
i += 2;
}
if (isCloseSubPath && currentSubPath.PointCount > 0)
{
currentSubPath.CloseFigure();
paths.Add(new GraphicsPathEntry
{
GraphicsPath = currentSubPath,
NeedsClosing = false
});
currentSubPath = null;
}
}
if (currentSubPath != null && currentSubPath.PointCount > 0)
{
paths.Add(new GraphicsPathEntry
{
GraphicsPath = currentSubPath,
NeedsClosing = false
});
}
}
private static ISvgRenderer? _svgRenderer;
}