From fe93e0f4b6c2da21f20e7e64fc591e2361e119dd Mon Sep 17 00:00:00 2001 From: Dan <226267877+CP0274@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:33:05 +0200 Subject: [PATCH 1/4] Add SOLIDREGION footprint shapes; fix missing 3D body layer assignment SOLIDREGION (filled copper polygon) shapes were parsed and then discarded (case returned null). Adds EeFootprintSolidRegion, wired into the shape dispatcher, plus EEPCB.CreateSolidRegion to build the IPCB_Region. "cutout" fill-rule regions are intentionally skipped (they're meant to subtract from an adjacent solid region - rendering one standalone would produce a visible extra blob, not a hole). Separately, EEPCB.CreateComponentBody never called SetState_V7Layer at all, unlike every other shape type in the file (pads, tracks, arcs, text) - a freshly-created 3D body was left at whatever the internal default layer is instead of the side the SVGNODE's own layerid specifies. Now resolves and applies the real layer, the same way every sibling shape already does. --- EasyEDA-Loader/EEPCB.cs | 25 ++++- EasyEDA-Loader/FootprintData.cs | 2 +- .../FootprintShapes/EeFootprint3dModel.cs | 25 +++-- .../FootprintShapes/EeFootprintSolidRegion.cs | 106 ++++++++++++++++++ 4 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 EasyEDA-Loader/FootprintShapes/EeFootprintSolidRegion.cs diff --git a/EasyEDA-Loader/EEPCB.cs b/EasyEDA-Loader/EEPCB.cs index 084f463..3929b28 100644 --- a/EasyEDA-Loader/EEPCB.cs +++ b/EasyEDA-Loader/EEPCB.cs @@ -1,6 +1,7 @@ using PCB; using System; +using System.Collections.Generic; namespace EasyEDA_Loader { @@ -132,7 +133,24 @@ public static IPCB_Text3 CreateText(IPCB_LibComponent c, TLayerConstant layer, s return textObject; } - public static IPCB_ComponentBody CreateComponentBody(IPCB_LibComponent c, string fileName, double rx, double ry, double rz, double x, double y, double z) + public static IPCB_Region CreateSolidRegion(IPCB_LibComponent c, TLayerConstant layer, List points) + { + var contour = AltiumApi.GlobalVars.PCBServer.PCBContourFactory(); + if (contour == null) return null; + foreach (var p in points) + { + contour.AddPoint(AltiumApi.MmToCoord(p.X) + c.GetState_XLocation(), AltiumApi.MmToCoord(p.Y) + c.GetState_YLocation()); + } + + var region = AltiumApi.GlobalVars.PCBServer.PCBObjectFactory(TObjectId.eRegionObject, TDimensionKind.eNoDimension, TObjectCreationMode.eCreate_Default) as IPCB_Region; + if (region == null) return null; + region.SetState_Kind(TRegionKind.eRegionKind_Copper); + region.SetOutlineContour(contour); + region.SetState_V7Layer(new V7_Layer(layer)); + return region; + } + + public static IPCB_ComponentBody CreateComponentBody(IPCB_LibComponent c, string fileName, double rx, double ry, double rz, double x, double y, double z, TLayerConstant layer) { var stepModel = AltiumApi.GlobalVars.PCBServer.PCBObjectFactory(TObjectId.eComponentBodyObject, TDimensionKind.eNoDimension, TObjectCreationMode.eCreate_Default) as IPCB_ComponentBody; if (stepModel == null) return null; @@ -140,6 +158,11 @@ public static IPCB_ComponentBody CreateComponentBody(IPCB_LibComponent c, string if (model == null) return null; model.SetState(rx, ry, rz, AltiumApi.MmToCoord(z)); stepModel.SetModel(model); + // Every other shape in this file (pads, tracks, arcs, text) sets its own layer via + // SetState_V7Layer - this call was missing entirely for component bodies, leaving a + // freshly-created body at whatever the internal default layer is instead of the side + // (top/bottom) the SVGNODE's own layerid actually specifies. + stepModel.SetState_V7Layer(new V7_Layer(layer)); // Model is created at the bottom-left origin of the board, so we need to offset it stepModel.MoveByXY(AltiumApi.MmToCoord(x) + c.GetState_Board().GetState_XOrigin(), AltiumApi.MmToCoord(y) + c.GetState_Board().GetState_YOrigin()); return stepModel; diff --git a/EasyEDA-Loader/FootprintData.cs b/EasyEDA-Loader/FootprintData.cs index 06eb202..1466ffd 100644 --- a/EasyEDA-Loader/FootprintData.cs +++ b/EasyEDA-Loader/FootprintData.cs @@ -250,7 +250,7 @@ public override EeFootprintShape ReadJson(JsonReader reader, Type objectType, Ee case "SVGNODE": return EeFootprint3dModel.FromString(raw); case "SOLIDREGION": - return null; + return EeFootprintSolidRegion.FromString(raw); } } diff --git a/EasyEDA-Loader/FootprintShapes/EeFootprint3dModel.cs b/EasyEDA-Loader/FootprintShapes/EeFootprint3dModel.cs index e83b785..dc45bf3 100644 --- a/EasyEDA-Loader/FootprintShapes/EeFootprint3dModel.cs +++ b/EasyEDA-Loader/FootprintShapes/EeFootprint3dModel.cs @@ -14,11 +14,13 @@ public static EeFootprint3dModel FromString(string data) { var parts = data.Split(new[] { "~" }, StringSplitOptions.None); SvgNode node = JsonConvert.DeserializeObject(parts[1]); - var originParts = node.Attrs.COrigin.Split(new[] { "," }, StringSplitOptions.None); var rotationParts = node.Attrs.CRotation.Split(new[] { "," }, StringSplitOptions.None); - double CenterX = double.Parse(originParts[0], System.Globalization.CultureInfo.InvariantCulture); - double CenterY = double.Parse(originParts[1], System.Globalization.CultureInfo.InvariantCulture); + // c_origin is wherever the 3D CAD tool's own workspace zero happened to be when the + // STEP model was authored - it has no relationship to where the model should be + // placed on the footprint, so it's not used here. The model is placed at the + // footprint's own local origin instead (the same reference point every other shape + // is anchored to via ConvertX/ConvertY). // Center compute, shouldnt be needed, the GL engine does this for verification of somekind /* @@ -49,12 +51,13 @@ public static EeFootprint3dModel FromString(string data) { Name = node.Attrs.Title, Uuid = node.Attrs.Uuid, + LayerId = node.Attrs.Layerid, Height = ConvertToMM(double.Parse(node.Attrs.CHeight, System.Globalization.CultureInfo.InvariantCulture)), Width = ConvertToMM(double.Parse(node.Attrs.CWidth, System.Globalization.CultureInfo.InvariantCulture)), Translation = new Vec3 { - X = ConvertToMM(CenterX), - Y = ConvertToMM(CenterY), + X = 0, + Y = 0, Z = ConvertToMM(double.Parse(node.Attrs.Z, System.Globalization.CultureInfo.InvariantCulture)) }, Rotation = new Vec3 @@ -105,13 +108,14 @@ public override bool AddToComponent(IPCB_LibComponent c, EeFootprintContext ctx) string temp = Path.Combine(Path.GetTempPath(), $"{Uuid}.step"); File.WriteAllBytes(temp, modelTask.Result); - // The translation is not quite right, the values shown in "3D Model Manager" are available from the Search API as "3D Model Transform" - // The Y axis is slightly off and I cannot figure out the missing piece maybe combination of rotation/y-flip/re-center causing this to be wrong - // Where the mesh starts X,Y in the EE model manager seems to differ from the computed one here // The Z is the lowest Z of the mesh plus the Z offset (hence why we download the Raw mesh and search for the lowest vert.z as this offset is not part of the info) - // Will leave this for now as it's "close enough" most of the time to only need a nudge by a few 10ths of a millimeter - var body = EEPCB.CreateComponentBody(c, temp, Rotation.X, Rotation.Y, Rotation.Z, ConvertX(Translation.X, ctx), ConvertY(Translation.Y, ctx), Translation.Z + heightTask.Result); + // Translation.X/Y are already local-origin coordinates (see FromString) - they land + // directly on the footprint's own origin without going through ConvertX/ConvertY, + // which would otherwise apply the bounding-box centering a second time. + var layer = ctx.Layers.GetLayer(LayerId); + var targetLayer = layer != null ? EEPCB.EELayerToAltium(layer.Name) : TLayerConstant.eTopLayer; + var body = EEPCB.CreateComponentBody(c, temp, Rotation.X, Rotation.Y, Rotation.Z, Translation.X, Translation.Y, Translation.Z + heightTask.Result, targetLayer); EEPCB.AddToPCB(c, body); File.Delete(temp); @@ -127,6 +131,7 @@ public override bool AddToComponent(IPCB_LibComponent c, EeFootprintContext ctx) public string Name { get; set; } public string Uuid { get; set; } + public string LayerId { get; set; } public double Height { get; set; } public double Width { get; set; } public Vec3 Translation { get; set; } diff --git a/EasyEDA-Loader/FootprintShapes/EeFootprintSolidRegion.cs b/EasyEDA-Loader/FootprintShapes/EeFootprintSolidRegion.cs new file mode 100644 index 0000000..b5c43ed --- /dev/null +++ b/EasyEDA-Loader/FootprintShapes/EeFootprintSolidRegion.cs @@ -0,0 +1,106 @@ +using PCB; +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace EasyEDA_Loader +{ + public class EeFootprintSolidRegion : EeFootprintShape + { + public static EeFootprintSolidRegion FromString(string data) + { + var parts = data.Split(new[] { "~" }, StringSplitOptions.None); + return new EeFootprintSolidRegion + { + LayerId = parts[1], + PathData = parts[3], + FillRule = parts[4], + Id = parts[5], + }; + } + + // EasyEDA's SOLIDREGION path data is a simple SVG-subset - only "M x y", "L x y" and a + // closing "Z" appear in every real sample (no curves/arcs within a region's own outline). + // The command letter is glued directly onto the first coordinate with no space (e.g. + // "M383.874048 315.27556", not "M 383.874048 ..."). Strips a leading command letter from + // each token instead of comparing whole tokens (which would never match and silently drop + // that coordinate), then pairs up every remaining number regardless of which original + // token it came from. + public static List ParsePath(string pathData) + { + var numbers = new List(); + var tokens = pathData.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var rawToken in tokens) + { + string token = rawToken; + if (token.Length > 0 && (token[0] == 'M' || token[0] == 'L' || token[0] == 'Z' || token[0] == 'z')) + { + token = token.Substring(1); + } + if (string.IsNullOrEmpty(token)) + continue; + if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out double num)) + { + numbers.Add(num); + } + } + + var points = new List(); + for (int i = 0; i + 1 < numbers.Count; i += 2) + { + points.Add(new EePoint { X = numbers[i], Y = numbers[i + 1] }); + } + return points; + } + + public override bool AddToComponent(IPCB_LibComponent c, EeFootprintContext ctx) + { + // "cutout" regions are meant to subtract from the preceding "solid" region (EasyEDA + // emits them as adjacent pairs sharing the same local coordinates, e.g. a paste-mask + // pad split into an outer solid block and an inner cutout to leave a border) - not + // implemented. Rendering a cutout as its own standalone solid shape would be actively + // wrong (a visible extra blob where there should be a hole), so these are skipped + // entirely rather than guessed at. + if (FillRule != "solid") + return true; + + var layer = ctx.Layers.GetLayer(LayerId); + if (layer == null) + return true; + + try + { + var targetLayer = EEPCB.EELayerToAltium(layer.Name); + + var rawPoints = ParsePath(PathData); + if (rawPoints.Count < 3) + return true; // not a real polygon + + var localPoints = new List(rawPoints.Count); + foreach (var p in rawPoints) + { + localPoints.Add(new EePoint { X = ConvertX(ConvertToMM(p.X), ctx), Y = ConvertY(ConvertToMM(p.Y), ctx) }); + } + + var region = EEPCB.CreateSolidRegion(c, targetLayer, localPoints); + if (region != null) + { + EEPCB.AddToPCB(c, region); + } + } + catch (Exception ex) + { + if (ctx.Exception != null && !ctx.Exception(ex)) + { + return false; + } + } + return true; + } + + public string LayerId { get; set; } + public string PathData { get; set; } + public string FillRule { get; set; } + public string Id { get; set; } + } +} From ec8d6fc91391d3709cdb665c1ac51c30bf5f1a24 Mon Sep 17 00:00:00 2001 From: Dan <226267877+CP0274@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:33:35 +0200 Subject: [PATCH 2/4] Add paste/solder mask expansion overrides for pads Two trailing fields in EasyEDA's PAD~... shape string (paste mask expansion, solder mask expansion) were parsed as raw string parts but never read or applied. Adds them as real properties and applies them via pad.GetState_Cache()/SetState_Cache(), the same V7_PadCache read-modify-write pattern EeFootprintHole.cs already uses to zero a hole's solder mask expansion. EasyEDA only carries a single solder value (no separate top/bottom), so both sides are set the same, matching that existing convention. --- .../FootprintShapes/EeFootprintPad.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/EasyEDA-Loader/FootprintShapes/EeFootprintPad.cs b/EasyEDA-Loader/FootprintShapes/EeFootprintPad.cs index f3ecaa4..597ec26 100644 --- a/EasyEDA-Loader/FootprintShapes/EeFootprintPad.cs +++ b/EasyEDA-Loader/FootprintShapes/EeFootprintPad.cs @@ -31,6 +31,8 @@ public static EeFootprintPad FromString(string data) HolePoints = EePoint.ListFromString(parts[14]), IsPlated = ParseBoolean(parts[15]), IsLocked = ParseBoolean(parts[16]), + PasteMaskExpansion = ConvertToMM(double.Parse(parts[17], System.Globalization.CultureInfo.InvariantCulture)), + SolderMaskExpansion = ConvertToMM(double.Parse(parts[18], System.Globalization.CultureInfo.InvariantCulture)), }; } public override List AddToCanvas(Canvas c, EeFootprintContext ctx) @@ -173,6 +175,19 @@ public override bool AddToComponent(IPCB_LibComponent c, EeFootprintContext ctx) } pad.SetState_HoleWidth(AltiumApi.MmToCoord(HoleLength)); } + // Same GetState_Cache/SetState_Cache pattern EeFootprintHole.cs already uses to + // zero a hole's solder mask expansion, reused here for the pad's own solder/paste + // mask expansion overrides (previously-parsed fields that were never read). + // EasyEDA only carries a single solder value (no separate top/bottom), so both + // sides are set the same, matching the convention EeFootprintHole.cs already uses. + var padCache = pad.GetState_Cache(); + padCache.SolderMaskExpansionValid = TCacheState.eCacheManual; + padCache.UseSeparateExpansions = true; + padCache.SolderMaskExpansion = AltiumApi.MmToCoord(SolderMaskExpansion); + padCache.SolderMaskBottomExpansion = AltiumApi.MmToCoord(SolderMaskExpansion); + padCache.PasteMaskExpansionValid = TCacheState.eCacheManual; + padCache.PasteMaskExpansion = AltiumApi.MmToCoord(PasteMaskExpansion); + pad.SetState_Cache(padCache); EEPCB.AddToPCB(c, pad); } catch (Exception ex) @@ -201,6 +216,8 @@ public override bool AddToComponent(IPCB_LibComponent c, EeFootprintContext ctx) public List HolePoints { get; set; } public bool IsPlated { get; set; } public bool IsLocked { get; set; } + public double SolderMaskExpansion { get; set; } + public double PasteMaskExpansion { get; set; } } } From fd856766770333073e4386a0f2921b6667d551e3 Mon Sep 17 00:00:00 2001 From: Dan <226267877+CP0274@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:35:26 +0200 Subject: [PATCH 3/4] Draw previously-unused schematic graphic shapes; support multi-part symbols Polyline/Polygon/Path shapes (diode arrows, etc.) were parsed from the start of this project but never drawn - only pins and a single bounding rectangle ever made it onto the schematic symbol. Adds EESCH.CreatePolygon (SCH.TObjectId.ePolygon via ISch_Polygon, 1-indexed SetState_Vertex) and a graphics pass in SymbolDrawing that maps each shape's raw EasyEDA coordinates onto the synthetic pin grid. Pins are laid out synthetically (grid position by count/order), independent of their original raw coordinates, so graphic shapes - only meaningful at their original coordinates - need a coordinate mapping to line up with them. Calibrates per-axis against the actual pins drawn on that axis (min/max raw position vs. synthetic position) rather than the whole bounding box, since EasyEDA's own graphic coordinates coincide with pin raw coordinates in the source data - this maps shapes exactly, not approximately. Falls back to a bbox-proportional mapping per-axis when fewer than 2 pins with distinct raw positions exist on that axis. Also refactors CreateComponent into a shared DrawPart (draws one part's pins/rectangle/graphics) plus a new CreateMultiPartComponent, which is what real multi-part support (next commit) needs to draw each subpart separately. --- EasyEDA-Loader/EESCH.cs | 33 ++++++ EasyEDA-Loader/SymbolDrawing.cs | 198 +++++++++++++++++++++++++++++++- 2 files changed, 230 insertions(+), 1 deletion(-) diff --git a/EasyEDA-Loader/EESCH.cs b/EasyEDA-Loader/EESCH.cs index 78bcb32..e02a96d 100644 --- a/EasyEDA-Loader/EESCH.cs +++ b/EasyEDA-Loader/EESCH.cs @@ -1,5 +1,6 @@ using EDP; using SCH; +using System.Collections.Generic; using System.Windows.Forms; namespace EasyEDA_Loader @@ -161,6 +162,38 @@ public static void CreateLine(ISch_Lib schLib, ISch_Component c, double x1, doub c.AddSchObject(line); } + // For filled schematic graphics (EasyEDA's PL/PG/PT shapes - polylines drawn closed, + // polygons, and paths, e.g. diode arrows), which were previously parsed but never drawn. + // ISch_Polygon (shared by ISch_Polyline) exposes SetState_VerticesCount + SetState_Vertex + // for building the outline point-by-point. Filled the same way CreateRectangle fills itself. + public static void CreatePolygon(ISch_Lib schLib, ISch_Component c, List points) + { + if (points == null || points.Count < 3) + return; + var polygon = AltiumApi.GlobalVars.SCHServer.SchObjectFactory(SCH.TObjectId.ePolygon, SCH.TObjectCreationMode.eCreate_Default) as ISch_Polygon; + if (polygon == null) + return; + + polygon.SetState_VerticesCount(points.Count); + for (int i = 0; i < points.Count; i++) + { + // SetState_Vertex is 1-indexed, not 0-indexed - without the "+ 1" the last vertex + // silently defaults to (0,0) and the rest are shifted one slot from what was set. + polygon.SetState_Vertex(i + 1, new DXP.Point + { + X = AltiumApi.MilsToCoord(points[i].X), + Y = AltiumApi.MilsToCoord(points[i].Y) + }); + } + polygon.SetState_LineWidth(TSize.eSmall); + polygon.SetState_Color(0); + polygon.SetState_AreaColor(0); + polygon.SetState_IsSolid(true); + polygon.SetState_OwnerPartId(schLib.GetState_CurrentSchComponentPartId()); + polygon.SetState_OwnerPartDisplayMode(schLib.GetState_CurrentSchComponentDisplayMode()); + c.AddSchObject(polygon); + } + public static void AssignFootprint(ISch_Component c, string libraryPath, string modelName, string modelMapping) { var modelType = "PCBLIB"; diff --git a/EasyEDA-Loader/SymbolDrawing.cs b/EasyEDA-Loader/SymbolDrawing.cs index b379908..5c0e7f1 100644 --- a/EasyEDA-Loader/SymbolDrawing.cs +++ b/EasyEDA-Loader/SymbolDrawing.cs @@ -73,6 +73,8 @@ static public TRotationBy90 FromOrientation(PinOrientation orientation) public double X { get; set; } public double Y { get; set; } + public double RawX { get; set; } + public double RawY { get; set; } public string Designator { get; set; } public string Name { get; set; } public TRotationBy90 Orientation { get; set; } @@ -83,6 +85,99 @@ static public TRotationBy90 FromOrientation(PinOrientation orientation) public class SymbolDrawing { + // Raw space-separated "x y x y ..." pairs, no unit conversion (stays in the same raw + // mil-like JSON units as BBox/rect throughout this file) - deliberately not reusing + // EePoint.ListFromString, which converts to mm internally and would mix units. + static List ParsePointPairs(string data) + { + var points = new List(); + var tokens = data.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i + 1 < tokens.Length; i += 2) + { + if (double.TryParse(tokens[i], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double x) + && double.TryParse(tokens[i + 1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double y)) + { + points.Add(new EePoint { X = x, Y = y }); + } + } + return points; + } + + // Same robust letter-stripping approach as EeFootprintSolidRegion.ParsePath - strips a + // leading SVG command letter from each token individually rather than comparing whole + // tokens, so it's correct whether the letter is glued to the first coordinate (as in PCB + // SOLIDREGION data) or space-separated from it (as seen in this schematic PT/path data, + // e.g. "M 414 280 L 420 290 L 427 280 Z "). Only M/L/Z seen in any real sample - no curves. + static List ParseSvgPath(string pathData) + { + var numbers = new List(); + var tokens = pathData.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var rawToken in tokens) + { + string token = rawToken; + if (token.Length > 0 && (token[0] == 'M' || token[0] == 'L' || token[0] == 'Z' || token[0] == 'z')) + { + token = token.Substring(1); + } + if (string.IsNullOrEmpty(token)) + continue; + if (double.TryParse(token, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double num)) + { + numbers.Add(num); + } + } + var points = new List(); + for (int i = 0; i + 1 < numbers.Count; i += 2) + { + points.Add(new EePoint { X = numbers[i], Y = numbers[i + 1] }); + } + return points; + } + + // Pins are laid out synthetically (grid position by count/order - see LayoutPins below), + // completely independent of the JSON's own raw pin coordinates. Graphic shapes (diode + // arrows, etc.), which ARE only meaningful at their original raw coordinates, can't be + // drawn 1:1 without landing somewhere unrelated to where the (synthetically repositioned) + // pins actually ended up. EasyEDA's own graphic coordinates literally coincide with pin + // raw coordinates in the source data, so calibrating the mapping against the actual pins + // used for a given axis (rather than the whole bounding box) makes shapes align with + // those pins exactly, not approximately. Falls back to a bbox-proportional approach + // per-axis only when there aren't at least 2 pins on that axis with distinct raw + // positions to calibrate from (e.g. a component with only top/bottom pins has nothing to + // calibrate Y from). + // + // Known limitation: simple 2-point (min/max) calibration per axis, not a full + // least-squares fit - fine for pins laid out linearly along one axis (which is how + // LayoutPins always arranges them), but wouldn't average out well for some hypothetical + // component with non-uniform raw pin spacing on one axis. + static (double Scale, double Offset) ComputeAxisCalibration(List raw, List synthetic, double bboxMin, double bboxSize, double rectMin, double rectSize) + { + double rawMin = double.PositiveInfinity, rawMax = double.NegativeInfinity; + double synMin = 0, synMax = 0; + bool found = false; + for (int i = 0; i < raw.Count; i++) + { + if (raw[i] < rawMin) { rawMin = raw[i]; synMin = synthetic[i]; } + if (raw[i] > rawMax) { rawMax = raw[i]; synMax = synthetic[i]; } + found = true; + } + if (found && rawMax > rawMin) + { + double scale = (synMax - synMin) / (rawMax - rawMin); + double offset = synMin - rawMin * scale; + return (scale, offset); + } + // Fallback: bbox-proportional. + double bboxScale = bboxSize > 0 ? rectSize / bboxSize : 0; + double bboxOffset = rectMin - bboxMin * bboxScale; + return (bboxScale, bboxOffset); + } + + static EePoint MapToSyntheticRect(EePoint raw, (double Scale, double Offset) calibX, (double Scale, double Offset) calibY) + { + return new EePoint { X = raw.X * calibX.Scale + calibX.Offset, Y = raw.Y * calibY.Scale + calibY.Offset }; + } + static void DistributeEvenly(List source, List> targets) { // Keep track of how many items are in each target list @@ -306,6 +401,8 @@ static public (AltiumSymbolRectangle, List) LayoutPins(List Shapes) } } - static public void CreateComponent(ISch_Lib schLib, ISch_Component component, string pcbLibraryPath, string package, SymbolData ee_symbol) + // Draws one part's pins/rectangle/graphics onto the component - does NOT assign + // footprint or add the component to the library, since for a multi-part component those + // only happen once, not once per part. + static public void DrawPart(ISch_Lib schLib, ISch_Component component, SymbolData ee_symbol) { (var rect, var pins) = SymbolDrawing.LayoutPins(ee_symbol.Shapes); EESCH.CreateRectangle(schLib, component, rect.X1, rect.Height - rect.Y1, rect.X2, rect.Height - rect.Y2); @@ -343,6 +443,102 @@ static public void CreateComponent(ISch_Lib schLib, ISch_Component component, st { EESCH.CreatePin(schLib, component, pin.X, rect.Height - pin.Y, pin.Designator, pin.Name, pin.Orientation, pin.Length, pin.PinType, pin.ShowName, null); } + + // Calibrate the graphic-shape mapping against the actual pins (see + // ComputeAxisCalibration above for why) instead of the whole bounding box. TOP/BOTTOM + // pins vary in X (Orientation eRotate90/eRotate270), LEFT/RIGHT pins vary in Y + // (eRotate180/eRotate0). + var xCalibRaw = new List(); + var xCalibSyn = new List(); + var yCalibRaw = new List(); + var yCalibSyn = new List(); + foreach (var p in pins) + { + if (p.Orientation == TRotationBy90.eRotate90 || p.Orientation == TRotationBy90.eRotate270) + { + xCalibRaw.Add(p.RawX); + xCalibSyn.Add(p.X); + } + else + { + yCalibRaw.Add(p.RawY); + yCalibSyn.Add(p.Y); + } + } + var calibX = ComputeAxisCalibration(xCalibRaw, xCalibSyn, ee_symbol.BoundingBox.X, ee_symbol.BoundingBox.Width, rect.X1, rect.Width); + var calibY = ComputeAxisCalibration(yCalibRaw, yCalibSyn, ee_symbol.BoundingBox.Y, ee_symbol.BoundingBox.Height, rect.Y1, rect.Height); + + // Previously-ignored graphic shapes (diode arrows, etc. - parsed since the start of + // this project, but never drawn, same gap as PCB SOLIDREGION shapes). See + // MapToSyntheticRect above for why these go through a scale-to-fit mapping instead of + // their raw coordinates directly. + foreach (var shape in ee_symbol.Shapes) + { + // Exact-type check, not is/OfType<>() - EeSymbolPolygon inherits EeSymbolPolyline, + // and a closed/filled polygon must not be drawn as an open polyline outline. + if (shape.GetType() == typeof(EeSymbolPolyline)) + { + var polyline = (EeSymbolPolyline)shape; + var rawPts = ParsePointPairs(polyline.Points); + var mapped = new List(); + foreach (var p in rawPts) + mapped.Add(MapToSyntheticRect(p, calibX, calibY)); + for (int i = 1; i < mapped.Count; i++) + { + EESCH.CreateLine(schLib, component, + mapped[i - 1].X, rect.Height - mapped[i - 1].Y, + mapped[i].X, rect.Height - mapped[i].Y); + } + } + else if (shape is EeSymbolPolygon polygon) + { + var rawPts = ParsePointPairs(polygon.Points); + var mapped = new List(); + foreach (var p in rawPts) + { + var m = MapToSyntheticRect(p, calibX, calibY); + mapped.Add(new EePoint { X = m.X, Y = rect.Height - m.Y }); + } + EESCH.CreatePolygon(schLib, component, mapped); + } + else if (shape is EeSymbolPath path) + { + var rawPts = ParseSvgPath(path.Paths); + var mapped = new List(); + foreach (var p in rawPts) + { + var m = MapToSyntheticRect(p, calibX, calibY); + mapped.Add(new EePoint { X = m.X, Y = rect.Height - m.Y }); + } + EESCH.CreatePolygon(schLib, component, mapped); + } + } + } + + static public void CreateComponent(ISch_Lib schLib, ISch_Component component, string pcbLibraryPath, string package, SymbolData ee_symbol) + { + DrawPart(schLib, component, ee_symbol); + EESCH.AssignFootprint(component, pcbLibraryPath, package, ""); + schLib.AddSchComponent(component); + } + + // Real multi-part support. Each subpart becomes a distinct, selectable Altium "part" + // within the one component (e.g. all 4 gates of a quad op-amp, or STM32MP135's 4 drawn + // pin groups) sharing one footprint - not just the first subpart's pins. + // SetState_PartCountNoPart0 (ISch_Component) and SetState_CurrentSchComponentPartId + // (ISch_Lib) declare the part count / select the part subsequently-created pins/shapes + // belong to. EESCH.CreatePin/CreateRectangle/CreateLine/CreatePolygon already tag + // themselves with schLib.GetState_CurrentSchComponentPartId() at creation time + // (unchanged, pre-existing code) - so setting the current part ID before drawing each + // subpart is sufficient. + static public void CreateMultiPartComponent(ISch_Lib schLib, ISch_Component component, string pcbLibraryPath, string package, List subparts) + { + component.SetState_PartCountNoPart0(subparts.Count); + for (int i = 0; i < subparts.Count; i++) + { + schLib.SetState_CurrentSchComponentPartId(i + 1); // Altium parts are 1-indexed + DrawPart(schLib, component, subparts[i].DataStr); + } EESCH.AssignFootprint(component, pcbLibraryPath, package, ""); schLib.AddSchComponent(component); } From c12e26bbcba366260b67578b881e4532010e056c Mon Sep 17 00:00:00 2001 From: Dan <226267877+CP0274@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:36:26 +0200 Subject: [PATCH 4/4] Wire up multi-part components; set default Comment field Component.cs gains Subparts/Subpart - multi-subpart components (op-amps with multiple gates, large MCUs whose pins are split across several drawn "parts") carry their real pin/shape data there instead of in the top-level dataStr, which is empty ("shape": []) for these parts. Without this property Newtonsoft silently dropped the whole "subparts" array, so only an empty placeholder symbol was ever built. EasyEDALoader.cs now dispatches to SymbolDrawing.CreateMultiPartComponent when subparts are present, and moves SetState_Current_SchComponent to before drawing instead of after - SetState_CurrentSchComponentPartId (used per-subpart to draw each part) needs the library's own "current component" context to already be the component being drawn, otherwise every subpart's pins land in the same part regardless of the ID set. Also sets the schematic component's default Comment field from the part's Manufacturer Part number. Comment isn't a plain parameter you add - it's a dedicated object retrieved via GetState_SchComment(), the same pattern already used for the designator via GetState_SchDesignator(). Previously nothing set it, so it stayed at its "*" placeholder. --- EasyEDA-Loader/Component.cs | 25 ++++++++++++++++++++++ EasyEDA-Loader/EasyEDALoader.cs | 37 +++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/EasyEDA-Loader/Component.cs b/EasyEDA-Loader/Component.cs index c6f31d7..c1e763f 100644 --- a/EasyEDA-Loader/Component.cs +++ b/EasyEDA-Loader/Component.cs @@ -149,6 +149,31 @@ public class ComponentInfo [JsonProperty("packageDetail")] public PackageDetail PackageDetail { get; set; } + + // Multi-subpart components (op-amps with multiple gates, big MCUs like STM32MP135 whose + // pins are split across several drawn "parts") carry their real pin/shape data here + // instead of in the top-level dataStr - the top-level one is empty ("shape": []) for + // these parts. See EasyEDALoader.cs for how this gets used. + [JsonProperty("subparts")] + public List Subparts { get; set; } + } + + public class Subpart + { + [JsonProperty("uuid")] + public string Uuid { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("dataStr")] + public SymbolData DataStr { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("thumb")] + public string Thumb { get; set; } } public class Root diff --git a/EasyEDA-Loader/EasyEDALoader.cs b/EasyEDA-Loader/EasyEDALoader.cs index ca442e3..212de92 100644 --- a/EasyEDA-Loader/EasyEDALoader.cs +++ b/EasyEDA-Loader/EasyEDALoader.cs @@ -99,7 +99,13 @@ private void Run( var root = selection.Root; var owner_id = root.Component.Owner.Uuid; var ee_footprint = root.Component.PackageDetail.Footprint; + // The top-level Symbol (root.Component.Symbol) has an empty shape list for + // multi-subpart components, but its Head.Parameters (Name/Pre) are still the + // correct ones for the component as a whole - a subpart's own Pre has a + // per-part suffix ("U?.1") that would be wrong here. Real per-part + // pins/shapes come from Subparts instead. var ee_symbol = root.Component.Symbol; + bool isMultiPart = root.Component.Subparts != null && root.Component.Subparts.Count > 0; string package = ee_footprint.Head.Parameters.Package; EeFootprint3dModel model = selection.Include3dModel ? ee_footprint.GetModel() : null; @@ -154,8 +160,22 @@ private void Run( var component = EESCH.CreateComponent(partName, description, ee_symbol.Head.Parameters.Pre); if (schLib != null && component != null) { + // Moved SetState_Current_SchComponent to before drawing, not after. + // SetState_CurrentSchComponentPartId (used per-subpart below to draw + // real multi-part components) needs the library's "current component" + // context to already be this component while per-part IDs are being + // set - otherwise every subpart's pins land in Part A regardless of + // the part ID set. + schLib.SetState_Current_SchComponent(component); AltiumApi.GlobalVars.PCBServer.PreProcess(); - SymbolDrawing.CreateComponent(schLib, component, pcbLibraryPath, package, ee_symbol); + if (isMultiPart) + { + SymbolDrawing.CreateMultiPartComponent(schLib, component, pcbLibraryPath, package, root.Component.Subparts); + } + else + { + SymbolDrawing.CreateComponent(schLib, component, pcbLibraryPath, package, ee_symbol); + } if (productInfo?.Parameters != null) { @@ -165,8 +185,21 @@ private void Run( } } + // Default Comment (shown as "*" until set). Comment isn't a plain + // parameter you add - it's a dedicated, always-present object + // retrieved via GetState_SchComment(), the same pattern already used + // above for the designator (GetState_SchDesignator()). Adding a + // separate parameter named "Comment" would create an unrelated extra + // parameter and leave the real comment field at its "*" default. + if (!string.IsNullOrEmpty(ee_symbol.Head.Parameters.ManufacturerPart)) + { + var comment = component.GetState_SchComment(); + comment.SetState_Text(ee_symbol.Head.Parameters.ManufacturerPart); + comment.SetState_ShowName(false); + comment.SetState_IsHidden(false); + } + AltiumApi.GlobalVars.PCBServer.PostProcess(); - schLib.SetState_Current_SchComponent(component); schLib.GraphicallyInvalidate(); schDocument.DoFileSave("SchLib"); }