cad.net 布局+视口
布局
删除布局并切换布局
可能Acad前端需要拦截切换布局,并加入刷新界面的原因,
数据库没有切换布局属性,例如db.Layout = ?
我们仍然需要通过布局管理器,而它的取值会经过WorkingDatabase,
所以后台打开图纸后,要设置WorkingDatabase,否则会报错 eSetFailed,
其实它就是一个全局变量,就像我们的事务栈一样.
那为什么不像 db.CLayer = "0"
它也会引起前端界面变动啊,
真是鬼知道桌子怎么想的.
官网论坛说此会失败:
using var tr = db.TransactionManager.StartOpenCloseTransaction();
// 前台,当前文档
[CommandMethod(nameof(Test2))]
public void Test2() {
using DBTrans tr = new();
DeleteVoidLayouts();
}
// 批量任务前台后台
[CommandMethod(nameof(Test1), CommandFlags.Session)]
public void Test1() {
// 当前进程已经打开的文档们
Dictionary<string, Document> docMap = new();
Document? oldDoc = null;
var dm = Acap.DocumentManager;
foreach (Document doc in dm) {
if (doc.IsDisposed || doc.ReadOnly) continue;
if (doc.IsActive) oldDoc = doc;
docMap.Add(doc.Name, doc); // doc.Name是文件而不是文件名
}
// 遍历文件夹获取dwg文件
var files = ...().ToHashSet();
// 除了只读文档,当前进程的文档们必然占用,排除掉,减少IO次数
var currentFiles = files.Where(file => docMap.ContainsKey(file)).ToList();
files.ExceptWith(currentFiles);
// 1,后台任务,进程没有打开的文件
var unoFiles = files.Where(file => !IsFileOpen(file)).ToHashSet();
foreach (var file in unoFiles) {
BackendTask(file, file, ()=> { DeleteVoidLayouts(); });
}
// 2,无法处理
foreach (var file in files.Except(unoFiles)) {
Env.Print("无法处理已经打开但不是本进程的文件: " + file);
}
// 3,前台任务,每个都激活为当前文档
// 为什么需要激活呢?因为前台打印需要,避免出现不同逻辑
foreach (var file in currentFiles) {
var doc = docMap[file];
if (!doc.IsActive) {
dm.MdiActiveDocument = doc;
HostApplicationServices.WorkingDatabase = doc.Database;
}
using var docLocker = doc.DocumentLock();
using (DBTrans tr = new(doc.Database)) {
DeleteVoidLayouts();
}
// 这里要发送异步命令去保存
// https://www.cnblogs.com/JJBox/p/12156778.html
doc.SendStringToExecute("_qsave\n", false, true, true);
}
// 恢复旧激活
if (oldDoc is not null) {
dm.MdiActiveDocument = oldDoc;
HostApplicationServices.WorkingDatabase = oldDoc.Database;
}
}
bool IsFileOpen(string file) {
try {
// adndev官网推荐用追加模式判断占用
using (FileStream fs = new(file,
FileMode.Append, FileAccess.Write, FileShare.None)) { }
return false;
} catch {}
return true;
}
// 后台并没有提供直接操作布局切换的方式,
// 我们仍然需要用布局管理器,而它需要设置WorkingDatabase
// 后台任务,打开图纸删除未使用布局
public void BackendTask(string dwgFile, string newFile, Action action) {
if (action is null)
throw new ArgumentNullException(nameof(action), "action is null");
// 关闭全部图纸执行此函数sdb是空的.
// 你可以关闭全部文档,然后发送win32API消息,这样就能跑此函数了.
Database? sdb = HostApplicationServices.WorkingDatabase;
if (sdb is null)
throw new ArgumentNullException("必须要存在一个前台文档");
try {
using var db = new Database(false, true);
db.ReadDwgFile(dwgFile, System.IO.FileShare.Read, false, "");
db.CloseInput(true);
HostApplicationServices.WorkingDatabase = db;
using (DBTrans tr = new(db)) {
action();
// 一旦用了布局管理器切换布局,始终在提交事务时候出错,
// AutoCAD错误中断
// 致命错误: Unhandled Access Violation Reading 0x0010 Exception at CFASC28h
// 怀疑是全局变量不能保留后台的引用,
// 我是想象成当它保存关闭后,全局变量此时显示一个已经不存在的引用了,
// 但是为什么是事务提交时候检查,而不是保存时候检查呢?我并不知道.
// 所以顶替回来居然好了...真是邪门玩意.
// 这样还说明了必须要存在一个前台文档,否则切换不回来.(可以set null吗)
HostApplicationServices.WorkingDatabase = sdb;
}
db.SaveAs(newFile, true, DwgVersion.Current, db.SecurityParameters);
} finally {
HostApplicationServices.WorkingDatabase = sdb;
}
}
/*
注释这种方式删除布局,会导致图纸损坏出现需要修复图纸.
layout.UpgradeOpen();
layout.Erase();
btr.UpgradeOpen();
btr.Erase();
*/
/// <summary>
/// 刪除未使用布局
/// </summary>
public void DeleteVoidLayouts() {
var tr = DBTrans.Top;
// 排除模型后,只有一个布局不能删除,否则破坏DWG结构
using var lm = LayoutManager.Current;
int layoutNum = lm.LayoutCount -1;
if (layoutNum == 1) return;
// 记录当前空间,用于还原,被删除则不处理
string? oldLayout = lm.CurrentLayout;
// 删除前切换到模型,避免悬空指针.
db.TileMode = true;
// 删除未使用布局,删除布局2,留下布局1.
// 按照名称排序
var olist = GetLayoutMap(db, tr)
.OrderByDescending(pair => pair.Key);
// 遍历并初始化,防止出错,需要提权.
foreach(var pair in olist) {
using var layout = (Layout)tr.GetObject(pair.Value, OpenMode.ForWrite);
layout.Initialize();
// 获取布局内容前要切换否则有错误,这个可能不需要
// lm.CurrentLayout = layout.Name;
int entCounter = 0;
int vpCounter = 0;
// 打开布局块表记录,视口数量如果大于2就结束.
// 有了初始化之后,
// 会有1号视口,加上图元视口,所以要大于2.
using var btr = (BlockTableRecord)tr.GetObject(layout.BlockTableRecordId, OpenMode.ForRead);
foreach (var entId in btr) {
if (!entId.IsOk()) continue;
entCounter++;
using var ent = tr.GetObject(entId, OpenMode.ForRead);
if (ent is Viewport) vpCounter++;
if (entCounter > 2) break;
}
if (entCounter > 2) continue;
// 没有图元 或 全是视口,就删除
if (entCounter == 0 || entCounter == vpCounter) {
if (layout.LayoutName == oldLayout) oldLayout = null;
lm.DeleteLayout(layout.LayoutName);
// 剩下唯一布局就终止任务
layoutNum--;
if (layoutNum == 1) break;
}
}
// 若已被删除,切换到模型,否则切换回旧布局
if (oldLayout is null) db.TileMode = true;
else lm.CurrentLayout = oldLayout;
}
/// <summary>
/// 激活第一个布局为当前布局
/// </summary>
/// <returns>(旧布局名,新布局名)若一样就是没改</returns>
public (string oldName, string newName) ActiveFirstLayout() {
var tr = DBTrans.Top;
using var lm = LayoutManager.Current;
var oldName = lm.CurrentLayout;
var newName = lm.CurrentLayout;
// TabOrder[0]是Model跳过它
using var layouts = (DBDictionary)tr.GetObject(tr.Database.LayoutDictionaryId);
foreach (DBDictionaryEntry entry in layouts) {
using var layout = (Layout)tr.GetObject(entry.Value);
if (layout.TabOrder == 1) {
if (oldName != layout.LayoutName) {
newName = layout.LayoutName;
lm.CurrentLayout = layout.LayoutName;
}
break;
}
}
return (oldName, newName);
}
布局名转为map
其实布局管理器就是一个map了...写完才知道...
就可以用layout.BlockTableRecordId
来打开遍历了.
这种应该是官方预留索引的方式:
db.NamedObjectsDict.GetAt("ACAD_LAYOUT")
// map<布局名,布局id>
public Dictionary<string, ObjectId> GetLayoutMap(Database db,
Transaction? tr = null, bool excludeModel = true) {
tr ??= DBTrans.Top;
Dictionary<string, ObjectId> map = new();
using var layouts = (DBDictionary)tr.GetObject(db.LayoutDictionaryId, OpenMode.ForRead);
foreach(DBDictionaryEntry entry in layouts) {
var layoutName = entry.Key;
var layoutId = entry.Value;
if (!layoutId.IsOk()) continue;
if (excludeModel && layoutName == "Model") continue;
map.Add(layoutName, layoutId);
}
return map;
}
// 和上面等价,不要用这个,这是遍历块表的,难怪我之前觉得怪怪的
// map<布局名,布局id>
public Dictionary<string, ObjectId> GetLayoutMap(Database db,
Transaction? tr = null, bool excludeModel = true) {
tr ??= DBTrans.Top;
Dictionary<string, ObjectId> map = new();
using var bt = (BlockTable)tr.GetObject(db.BlockTableId, OpenMode.ForRead);
foreach (ObjectId id in bt)
if (!id.IsOk()) continue;
using var btr = (BlockTableRecord)tr.GetObject(id, OpenMode.ForRead);
if (!btr.IsLayout) continue;
if (excludeModel && btr.Name == "*Model_Space") continue;
map.Add(btr.Name, btr.LayoutId);
}
return map;
}
统计布局的图框数量
// map<布局名,图框数量>
// 其实还可以写新函数 map<布局名,(图框名,图框数量)>
public Dictionary<string, int> GetTkMap(Dictionary<string, ObjectId> layoutMap, Transaction? tr = null) {
tr ??= DBTrans.Top;
Dictionary<string, int> map = new();
foreach(var pair in layoutMap) {
using var layout = (Layout)tr.GetObject(pair.Value, OpenMode.ForRead);
using var btr = (BlockTableRecord)tr.GetObject(layout.BlockTableRecordId, OpenMode.ForRead);
int tkNum = 0;
foreach (var entId in btr) {
if (!entId.IsOk()) continue;
using var ent = tr.GetObject(entId, OpenMode.ForRead);
if (ent is not BlockReference brf) continue;
if (brf.Name.Contains("图框") || brf.Name.Contains("TK"))
tkNum++;
}
map.Add(pair.Key, tkNum);
}
return map;
}
public void PrintTkNum(Dictionary<string, int> tkMap) {
foreach (var pair in tkMap) {
Env.Print($"布局名: {pair.Key}");
Env.Print($"图框数量: {pair.Value}");
}
}
创建布局
创建布局最好不要和视口在同一个事务内.
为什么需要这样?
因为你想想只用一个事务时,
撤回可能先回滚视口,此时布局还不存在,出现一个悬空指针.
而桌子没有处理这种行为.
不管桌子有没有处理,
基于肯定不处理的形式写确定性
代码,我们分两次提交事务.
public class TestCreateLayoutCommands {
[CommandMethod(nameof(TestCreateLayout))]
public void TestCreateLayout() {
// 1,布局管理器
// 后台使用前设置工作数据库(前台设置也没事)
var dm = Acap.DocumentManager;
var doc = dm.MdiActiveDocument;
HostApplicationServices.WorkingDatabase = doc.Database;
LayoutManager lm = LayoutManager.Current;
var name = "新的布局";
// 2,创建布局后必须立即提交事务
// 创建并初始化工作,构造1号视口.
if (!lm.LayoutExists(name)) {
using DBTrans tr2 = new();
var layoutId2 = lm.CreateLayout(name);
using var layout2 = (Layout)tr2.GetObject(layoutId2, OpenMode.ForWrite);
layout2.Initialize();
}
// 3,获取布局内容前要切换,(可能有错误?)
// lm.CurrentLayout = name;
// 开新事务做其后的工作
using DBTrans tr = new();
// 修改布局内容
// 方案一: 获取全部视口,包含1号视口和-1号.
// 跳过一号视口,删除其他视口,你不能用写模式访问1号视口!
var layoutId = lm.GetLayoutId(name);
using var layout = (Layout)tr.GetObject(layoutId);
using var ids = layout.GetViewports();
foreach(var vpId in ids) {
using var vp = (Viewport)tr.GetObject(vpId);
if (vp.Number == 1) continue;
vp.UpgradeOpen();
vp.Erase();
}
// 方案二: 获取全部视口,包含1号视口和-1号.
// 布局转为块表记录,遍历每个图元
using var layout = (Layout)tr.GetObject(layoutId, OpenMode.ForRead);
using var btr = (BlockTableRecord)tr.GetObject(layout.BlockTableRecordId, OpenMode.ForRead);
foreach (var entId in btr) {
}
}
}
// 其他例子....
// 切换布局,通过id形式.
lm.SetCurrentLayoutId(lm.GetLayoutId(name));
视口
创建视口
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Runtime;
using Autodesk.AutoCAD.Geometry;
[CommandMethod("CreateViewport")]
public void CreateViewport() {
Document doc = Application.DocumentManager.MdiActiveDocument;
Database db = doc.Database;
using var tr = db.TransactionManager.StartTransaction();
// 打开布局空间
var ps = (BlockTableRecord)tr.GetObject(db.CurrentSpaceId, OpenMode.ForWrite);
// 创建一个新的视口
var vp = new Viewport();
vp.SetDatabaseDefaults();
// 布局的视口图形边界:矩形视口计算边界中心点
double left = rect.Left;
double bottom = rect.Bottom;
double right = rect.Right;
double top = rect.Top;
vp.CenterPoint = new Point3d((left + right) / 2, (bottom + top) / 2, 0);
vp.Width = right-left;
vp.Height = top-bottom;
// 视口的视图:
// 绕点:视图目标点,一般情况下和模型原点一致,相对坐标下它会被更改.
vp.ViewTarget = new Point3d(0,0,0);
// 视点:视图观察点
vp.ViewCenter = new Point2d(0, 0);
// 转轴:视图观察方向(Z轴,朝向屏幕)
vp.ViewDirection = new Vector3d(0, 0, 1);
// 扭曲夹角
// 不改变 ViewCenter 和 ViewDirection 的情况下得到旋转的视觉效果
// 但是如果有三维视图就变得复杂和彷徨无措起来.
vp.TwistAngle = 0.0;
// 自定义比例,也就是比例因子
vp.CustomScale = 1.0 / 100.0;
tr.AddNewlyCreatedDBObject(vp, true);
ps.AppendEntity(vp);
vp.On = true;
vp.UpdateDisplay();
tr.Commit();
}
三者关系
绕点,视点,角度,它们三者的关系
public void UpdateViewCenter(Viewport vp, Point3d basept, Point3d target) {
// 1. 获取视口的关键参数
Point3d vt = vp.ViewTarget; // 绕点,DCS的原点
Point2d vc = vp.ViewCenter; // 视点,DCS的点,从绕点开始计算.
Vector3d vd = vp.ViewDirection; // 转轴
// 2. 计算旋转角度
// 扭角是依照转轴计算,
// 而在Z轴旋转的时候从X轴逆时针计算而已.
double wa = 2 * Math.PI - vp.TwistAngle;
// 3. 构造旋转矩阵
Matrix3d roMatrix = RotationMatrix(wa, vd);
// 4. 平移和正旋转
// 视点相加还是DCS,
// 只是采用Point3d.Origin旋转简化运算
Point3d pt = vt + vc;
pt = pt * roMatrix;
// 5. 处理坐标系转换和平移
// 视点根据交互两点进行平移到目标向量
// 这交互的两点是切换回去模型再交互的,
// 要保证坐标系一样,所以说UCS转WCS
basept = ConvertToDcs(basept);
target = ConvertToDcs(target);
pt = pt - basept + target;
// 6. 逆旋转和平移
pt = pt * roMatrix.Inverse();
pt = pt - vt;
// 7. 更新视点
vp.ViewCenter = pt;
}
// Acad不需要写 罗德里格公式, 而是调用就好了
// 求逆Matrix.Inverse()
// 这个世界有两种人,一种人会把角度改为负数,一种人会改转轴方向
// 将世界坐标转换为视图坐标,
public static Matrix3d WorldToEye(Viewport view) =>
Matrix3d.WorldToPlane(view.ViewDirection) *
Matrix3d.Displacement(view.ViewTarget.GetAsVector().Negate()) *
Matrix3d.Rotation(view.ViewTwist, view.ViewDirection, view.ViewTarget);
// 这里夹角可能需要加个负号
// 计算旋转矩阵(罗德里格公式)
public static Matrix3d RotationMatrix(double angle, Vector3d axis) {
// 单位化旋转轴
axis = axis.Normalize();
double kx = axis.X;
double ky = axis.Y;
double kz = axis.Z;
double cosθ = Math.Cos(angle);
double sinθ = Math.Sin(angle);
// 计算各个矩阵元素的值
double m00 = cosθ + (1 - cosθ) * kx * kx;
double m01 = kx * ky * (1 - cosθ) - kz * sinθ;
double m02 = kx * kz * (1 - cosθ) + ky * sinθ;
double m10 = ky * kx * (1 - cosθ) + kz * sinθ;
double m11 = cosθ + (1 - cosθ) * ky * ky;
double m12 = ky * kz * (1 - cosθ) - kx * sinθ;
double m20 = kz * kx * (1 - cosθ) - ky * sinθ;
double m21 = kz * ky * (1 - cosθ) + kx * sinθ;
double m22 = cosθ + (1 - cosθ) * kz * kz;
// 创建矩阵实例
return new Matrix3d(
m00, m01, m02,
m10, m11, m12,
m20, m21, m22
);
}
// 应用,也就是矩阵乘法
private Point3d MultiplyMatrixPoint(Matrix3d matrix, Point3d point) {
double x = matrix[0] * point.X + matrix[1] * point.Y + matrix[2] * point.Z;
double y = matrix[3] * point.X + matrix[4] * point.Y + matrix[5] * point.Z;
double z = matrix[6] * point.X + matrix[7] * point.Y + matrix[8] * point.Z;
return new Point3d(x, y, z);
}
private Point3d ConvertToDcs(Point3d ucsPoint)
{
// UCS→WCS转换
Point3d wcsPoint = ucsPoint.UcsToWcs();
// WCS→DCS转换
return wcsPoint.WcsToDcs();
}
注释比例
这个AI生成的不知道对不对
// 获取当前文档和数据库
Document doc = Application.DocumentManager.MdiActiveDocument;
Database db = doc.Database;
using (Transaction tr = db.TransactionManager.StartTransaction())
{
// 获取当前视口
Viewport vp = doc.ActiveViewport;
// 创建注释比例对象
AnnotationScale asd = new();
asd.Scale = 1.0; // 设置注释比例为1:1
asd.Custom = false; // 设置为非自定义注释比例
// 将注释比例应用到视口
vp.AnnotationScaleId = asd;
tr.Commit();
}
任意轴
众所周知,Z轴的旋转是从X轴开始的,并且逆时针为正.
逆时针好懂,因为是右手坐标系决定旋转为正的方向.
但是为什么是X轴呢?其实很简单,是基于自然约定.
如何自然呢?一个3*3的单位矩阵是这样的:
1,0,0
0,1,0
0,0,1
可以看见这个斜三角的美.
单位矩阵延伸出三个基础旋转矩阵,
即为绕X旋转矩阵,绕Y旋转矩阵,绕Z旋转矩阵,
然后就又回到了X轴了
那么三维任意轴的话,按理说有无数根起始轴啊?
此时又是怎么确定起始轴呢?
让我们问问AI,得到的信息是,直接通过 罗德里格公式.
罗德里格公式,它本质上就是Z轴旋转矩阵的一种泛化,
它需要两个参数,
一个是转轴,一个是角度,得到一个旋转矩阵.
也就是你可以很笨的先从任意轴重合到Z轴,
重合这个不难理解,求三个夹角,然后三个轴分别旋转一次就行了.
再实现依照Z轴旋转,最后逆转回去.
不过由于太笨了...罗德里格带着它的公式走来了.
不过,在Acad上面写个啥矩阵,肯定有函数已经写了.
// 将世界坐标转换为视图坐标
// 这个世界有两种人,一种人会把角度改为负数,一种人会改转轴方向
public static Matrix3d WorldToEye(this AbstractViewTableRecord view) =>
Matrix3d.WorldToPlane(view.ViewDirection) *
Matrix3d.Displacement(view.Target.GetAsVector().Negate()) *
Matrix3d.Rotation(view.ViewTwist, view.ViewDirection, view.Target);
首先是根据右手定则,表示旋转数值为正的方向.
其次是单位化向量,但是单位化之后仍然不能确定起始轴,
浅薄的认识,
平行分量和垂直分量构成公式,
点乘是两根向量组成直角三角形,
那么夹角+一根向量的逆运算就可以求出另一个根.
// 示例:绕任意轴(0.7, 0.9, 6.2) 旋转45°(即π/4弧度)
public static void Main2()
{
Vector3 axis = new Vector3(0.7f, 0.9f, 6.2f);
double angle = Math.PI / 4; // 逆时针
Matrix4x4 rotationMatrix = RotationMatrix(axis, angle);
// 应用旋转矩阵到点(1,0,0)
Vector3 point = new Vector3(1, 0, 0);
Vector3 rotatedPoint = Vector3.Transform(point, rotationMatrix);
Console.WriteLine($"旋转后的点: ({rotatedPoint.X:F4}, {rotatedPoint.Y:F4}, {rotatedPoint.Z:F4})");
}
using System;
using System.Numerics;
public class RotationExample
{
// 计算旋转矩阵(罗德里格公式)
public static Matrix4x4 RotationMatrix(Vector3 axis, double angle)
{
// 逆时针为正
angle = -angle;
// 单位化旋转轴(转换为double计算)
double x = axis.X, y = axis.Y, z = axis.Z;
double norm = Math.Sqrt(x * x + y * y + z * z);
double ux = x / norm, uy = y / norm, uz = z / norm;
double costheta = Math.Cos(angle);
double sintheta = Math.Sin(angle);
double oneMinusCostheta = 1 - costheta;
// 计算旋转矩阵元素
double m00 = costheta + oneMinusCostheta * ux * ux;
double m01 = oneMinusCostheta * ux * uy - sintheta * uz;
double m02 = oneMinusCostheta * ux * uz + sintheta * uy;
double m10 = oneMinusCostheta * uy * ux + sintheta * uz;
double m11 = costheta + oneMinusCostheta * uy * uy;
double m12 = oneMinusCostheta * uy * uz - sintheta * ux;
double m20 = oneMinusCostheta * uz * ux - sintheta * uy;
double m21 = oneMinusCostheta * uz * uy + sintheta * ux;
double m22 = costheta + oneMinusCostheta * uz * uz;
// 转换为float类型并构造Matrix4x4
return new Matrix4x4(
(float)m00, (float)m01, (float)m02, 0,
(float)m10, (float)m11, (float)m12, 0,
(float)m20, (float)m21, (float)m22, 0,
0, 0, 0, 1
);
}
// 两个向量生成旋转矩阵
public static Matrix4x4 RotationBetweenVectors(Vector3 from, Vector3 to)
{
// 计算旋转轴(叉积)
Vector3 axis = Vector3.Cross(from, to);
// 计算夹角(点积)
double dot = Vector3.Dot(from, to);
double angle = Math.Acos(dot / (from.Length() * to.Length())); // 已转为弧度
return RotationMatrix(axis, angle);
}
public static void Main()
{
// 测试参数: 旋转轴(0,0,1), 角度π/2弧度, 要逆时针为正
Vector3 axis = new Vector3(0, 0, 1);
double angle = Math.PI / 2;
// 计算旋转矩阵
Matrix4x4 rotationMatrix = RotationMatrix(axis, angle);
// 验证标准基向量变换
Vector3 pointX = new Vector3(1, 0, 0);
Vector3 rotatedX = Vector3.Transform(pointX, rotationMatrix);
Console.WriteLine($"X轴向量(1,0,0)旋转后:({rotatedX.X:F2}, {rotatedX.Y:F2}, {rotatedX.Z:F2})");
Vector3 pointY = new Vector3(0, 1, 0);
Vector3 rotatedY = Vector3.Transform(pointY, rotationMatrix);
Console.WriteLine($"Y轴向量(0,1,0)旋转后:({rotatedY.X:F2}, {rotatedY.Y:F2}, {rotatedY.Z:F2})");
Vector3 pointZ = new Vector3(0, 0, 1);
Vector3 rotatedZ = Vector3.Transform(pointZ, rotationMatrix);
Console.WriteLine($"Z轴向量(0,0,1)旋转后:({rotatedZ.X:F2}, {rotatedZ.Y:F2}, {rotatedZ.Z:F2})");
// 预期输出:
// (0.00, 1.00, 0.00)
// (-1.00, 0.00, 0.00)
// (0.00, 0.00, 1.00)
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 提示词工程——AI应用必不可少的技术
· 地球OL攻略 —— 某应届生求职总结
· 字符编码:从基础到乱码解决
· SpringCloud带你走进微服务的世界