diff --git a/apps/operator-backend/src/allowlist.ts b/apps/operator-backend/src/allowlist.ts index 64cf8bc..d165df4 100644 --- a/apps/operator-backend/src/allowlist.ts +++ b/apps/operator-backend/src/allowlist.ts @@ -130,6 +130,9 @@ const defaultAllowed: Allowlist = { "/revit/create-revision-cloud", "/revit/keynotes", "/revit/tag-elements", + "/revit/plan-family-evolution", + "/revit/apply-family-evolution", + "/revit/read-family-evolution", "/revit/create-dimension", "/revit/create-sheet", "/revit/create-sheets", diff --git a/apps/revit-bridge-addin/RevitBridge.Common.Tests/FamilyEvolutionPlanTests.cs b/apps/revit-bridge-addin/RevitBridge.Common.Tests/FamilyEvolutionPlanTests.cs new file mode 100644 index 0000000..ce8c80d --- /dev/null +++ b/apps/revit-bridge-addin/RevitBridge.Common.Tests/FamilyEvolutionPlanTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using RevitBridge.Common.FamilyEvolution; +using Xunit; + +namespace RevitBridge.Common.Tests +{ + public sealed class FamilyEvolutionPlanTests + { + [Theory] + [InlineData(1.70, 0.0, "right")] + [InlineData(-1.70, 0.0, "left")] + [InlineData(0.0, 2.05, "front")] + [InlineData(0.0, -2.05, "back")] + public void ResolveConnectorSide_UsesNearestEquipmentEdge(double x, double y, string expected) + { + Assert.Equal(expected, FamilyEvolutionPlan.ResolveConnectorSide(x, y, 10.0 / 3.0, 4.0)); + } + + [Fact] + public void ResolveConnectorSide_MatchesHru403ElectricalConnectorFixture() + { + var side = FamilyEvolutionPlan.ResolveConnectorSide(1.715879265091864, -1.361548556430455, 3.33333333333333, 4.03543307086614); + Assert.Equal("right", side); + } + + [Fact] + public void ResolveConnectorSide_FailsClosedAtAmbiguousCorner() + { + Assert.Throws(() => FamilyEvolutionPlan.ResolveConnectorSide(2.0, 2.0, 4.0, 4.0)); + } + + [Fact] + public void ResolveConnectorSide_FailsClosedForInteriorConnector() + { + Assert.Throws(() => FamilyEvolutionPlan.ResolveConnectorSide(1.0, 0.25, 4.0, 4.0)); + } + + [Fact] + public void BuildClearanceRectangle_RightSideStartsAtEquipmentEdgeAndExtendsThirtySixInches() + { + var segments = FamilyEvolutionPlan.BuildClearanceRectangle(44.0 / 12.0, 50.0 / 12.0, 3.0, "right"); + Assert.Equal(4, segments.Count); + Assert.Equal(22.0 / 12.0, segments[0].X1, 8); + Assert.Equal(58.0 / 12.0, segments[0].X2, 8); + Assert.Equal(-25.0 / 12.0, segments[0].Y1, 8); + Assert.Equal(25.0 / 12.0, segments[2].Y1, 8); + } + + [Fact] + public void ComputePlanHash_IsOrderIndependentAndDetectsDrift() + { + var a = FamilyEvolutionPlan.ComputePlanHash(new[] + { + new KeyValuePair("instance", "1465049"), + new KeyValuePair("width", "3.66666667") + }); + var b = FamilyEvolutionPlan.ComputePlanHash(new[] + { + new KeyValuePair("width", "3.66666667"), + new KeyValuePair("instance", "1465049") + }); + var drift = FamilyEvolutionPlan.ComputePlanHash(new[] + { + new KeyValuePair("instance", "1465049"), + new KeyValuePair("width", "3.75") + }); + Assert.Equal(a, b); + Assert.NotEqual(a, drift); + } + } +} diff --git a/apps/revit-bridge-addin/RevitBridge.Common/FamilyEvolution/FamilyEvolutionPlan.cs b/apps/revit-bridge-addin/RevitBridge.Common/FamilyEvolution/FamilyEvolutionPlan.cs new file mode 100644 index 0000000..db1822d --- /dev/null +++ b/apps/revit-bridge-addin/RevitBridge.Common/FamilyEvolution/FamilyEvolutionPlan.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace RevitBridge.Common.FamilyEvolution +{ + public sealed class ClearanceSegment + { + public ClearanceSegment(double x1, double y1, double x2, double y2) + { + X1 = x1; + Y1 = y1; + X2 = x2; + Y2 = y2; + } + + public double X1 { get; } + public double Y1 { get; } + public double X2 { get; } + public double Y2 { get; } + } + + public static class FamilyEvolutionPlan + { + private const string Schema = "revit-operator-family-evolution-v1"; + + public static string ResolveConnectorSide( + double connectorX, + double connectorY, + double widthFt, + double depthFt, + double ambiguityToleranceFt = 1.0 / 96.0, + double maximumEdgeDistanceFt = 0.25) + { + RequirePositiveFinite(widthFt, nameof(widthFt)); + RequirePositiveFinite(depthFt, nameof(depthFt)); + if (!Finite(connectorX) || !Finite(connectorY)) + throw new ArgumentOutOfRangeException("Connector coordinates must be finite."); + if (!Finite(ambiguityToleranceFt) || ambiguityToleranceFt < 0) + throw new ArgumentOutOfRangeException(nameof(ambiguityToleranceFt)); + if (!Finite(maximumEdgeDistanceFt) || maximumEdgeDistanceFt <= 0) + throw new ArgumentOutOfRangeException(nameof(maximumEdgeDistanceFt)); + + var halfWidth = widthFt / 2.0; + var halfDepth = depthFt / 2.0; + var distances = new[] + { + new KeyValuePair("right", Math.Abs(connectorX - halfWidth)), + new KeyValuePair("left", Math.Abs(connectorX + halfWidth)), + new KeyValuePair("front", Math.Abs(connectorY - halfDepth)), + new KeyValuePair("back", Math.Abs(connectorY + halfDepth)) + }.OrderBy(x => x.Value).ThenBy(x => x.Key, StringComparer.Ordinal).ToList(); + + if (distances.Count > 1 && Math.Abs(distances[1].Value - distances[0].Value) <= ambiguityToleranceFt) + throw new InvalidOperationException("Electrical connector is ambiguous between two family sides."); + if (distances[0].Value > maximumEdgeDistanceFt) + throw new InvalidOperationException("Electrical connector is not close enough to a family side to infer a clearance side safely."); + return distances[0].Key; + } + + public static IReadOnlyList BuildClearanceRectangle( + double widthFt, + double depthFt, + double offsetFt, + string side) + { + RequirePositiveFinite(widthFt, nameof(widthFt)); + RequirePositiveFinite(depthFt, nameof(depthFt)); + RequirePositiveFinite(offsetFt, nameof(offsetFt)); + side = NormalizeSide(side); + + var halfWidth = widthFt / 2.0; + var halfDepth = depthFt / 2.0; + double minX; + double maxX; + double minY; + double maxY; + switch (side) + { + case "right": + minX = halfWidth; + maxX = halfWidth + offsetFt; + minY = -halfDepth; + maxY = halfDepth; + break; + case "left": + minX = -halfWidth - offsetFt; + maxX = -halfWidth; + minY = -halfDepth; + maxY = halfDepth; + break; + case "front": + minX = -halfWidth; + maxX = halfWidth; + minY = halfDepth; + maxY = halfDepth + offsetFt; + break; + case "back": + minX = -halfWidth; + maxX = halfWidth; + minY = -halfDepth - offsetFt; + maxY = -halfDepth; + break; + default: + throw new InvalidOperationException("Unsupported clearance side."); + } + + return new[] + { + new ClearanceSegment(minX, minY, maxX, minY), + new ClearanceSegment(maxX, minY, maxX, maxY), + new ClearanceSegment(maxX, maxY, minX, maxY), + new ClearanceSegment(minX, maxY, minX, minY) + }; + } + + public static string ComputePlanHash(IEnumerable> fields) + { + if (fields == null) throw new ArgumentNullException(nameof(fields)); + var rows = fields.Select(field => + { + var key = (field.Key ?? string.Empty).Trim(); + var value = field.Value ?? string.Empty; + if (key.Length == 0 || key.IndexOf('\r') >= 0 || key.IndexOf('\n') >= 0) + throw new ArgumentException("Plan hash keys must be nonblank single-line strings.", nameof(fields)); + if (value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0) + throw new ArgumentException("Plan hash values must be single-line strings.", nameof(fields)); + return key + "=" + value; + }).OrderBy(x => x, StringComparer.Ordinal).ToList(); + if (rows.Count == 0) throw new ArgumentException("At least one plan field is required.", nameof(fields)); + + using (var sha = SHA256.Create()) + { + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(Schema + "\n" + string.Join("\n", rows))); + return string.Concat(bytes.Select(x => x.ToString("x2", CultureInfo.InvariantCulture))); + } + } + + public static string CanonicalNumber(double value) + { + if (!Finite(value)) throw new ArgumentOutOfRangeException(nameof(value)); + return Math.Round(value, 8, MidpointRounding.AwayFromZero).ToString("0.########", CultureInfo.InvariantCulture); + } + + public static string NormalizeSide(string side) + { + var normalized = (side ?? string.Empty).Trim().ToLowerInvariant().Replace('-', '_').Replace(' ', '_'); + if (normalized == "top") normalized = "front"; + if (normalized == "bottom") normalized = "back"; + if (normalized != "right" && normalized != "left" && normalized != "front" && normalized != "back") + throw new ArgumentException("clearance.side must be right, left, front, back, or power_connection.", nameof(side)); + return normalized; + } + + private static void RequirePositiveFinite(double value, string name) + { + if (!Finite(value) || value <= 0) throw new ArgumentOutOfRangeException(name); + } + + private static bool Finite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } +} diff --git a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/Families/FamilyEvolutionHandlers.cs b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/Families/FamilyEvolutionHandlers.cs new file mode 100644 index 0000000..0c86549 --- /dev/null +++ b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/Families/FamilyEvolutionHandlers.cs @@ -0,0 +1,927 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; +using Autodesk.Revit.DB; +using Autodesk.Revit.UI; +using RevitBridge.Common; +using RevitBridge.Common.FamilyEvolution; +using RevitBridge.Logic.Handlers.Core; +using RevitBridge.Logic.Handlers.MEP; + +namespace RevitBridge.Logic.Handlers +{ + public sealed class FamilyEvolutionClearanceSpec + { + public string side { get; set; } = "power_connection"; + public string offset { get; set; } = "36 in"; + public string lineStyleName { get; set; } = "Operator - Clearance Light Dashed"; + public string linePatternName { get; set; } = "Operator - Clearance Dash"; + public int lineWeight { get; set; } = 1; + public int red { get; set; } = 160; + public int green { get; set; } = 160; + public int blue { get; set; } = 160; + } + + public class FamilyEvolutionRequest + { + public long instanceId { get; set; } + public string? expectedUniqueId { get; set; } + public string? expectedMark { get; set; } + public string? expectedFamilyName { get; set; } + public string? expectedTypeName { get; set; } + public string newFamilyName { get; set; } = ""; + public string newTypeName { get; set; } = ""; + public string widthParameterName { get; set; } = "Width"; + public string depthParameterName { get; set; } = "Length"; + public string width { get; set; } = ""; + public string depth { get; set; } = ""; + public FamilyEvolutionClearanceSpec? clearance { get; set; } + public string? familySavePath { get; set; } + } + + public sealed class PlanFamilyEvolutionHandler : IRequestHandler + { + public sealed class Params : FamilyEvolutionRequest { } + + public Task Handle(UIApplication app, string jsonData) + { + var request = FamilyEvolutionService.Deserialize(jsonData); + var plan = FamilyEvolutionService.Prepare(app, request); + return Task.FromResult(FamilyEvolutionService.ToPlanReceipt(plan)); + } + } + + public sealed class ApplyFamilyEvolutionHandler : IRequestHandler + { + public sealed class Params : FamilyEvolutionRequest + { + public string planHash { get; set; } = ""; + public string confirm { get; set; } = ""; + } + + public Task Handle(UIApplication app, string jsonData) + { + var request = FamilyEvolutionService.Deserialize(jsonData); + var plan = FamilyEvolutionService.Prepare(app, request); + if (!string.Equals((request.planHash ?? "").Trim(), plan.PlanHash, StringComparison.Ordinal)) + throw new OperatorToolUserErrorException( + "Family evolution plan is stale or does not match this request.", + "family_evolution_plan_hash_mismatch"); + + var requiredConfirm = FamilyEvolutionService.RequiredConfirm(plan.InstanceId); + if (!BulkConfirmUtil.EqualsNormalized(request.confirm, requiredConfirm)) + throw new OperatorToolUserErrorException( + "Family evolution requires typed confirmation.", + "family_evolution_confirm_required", + requiredConfirm: requiredConfirm, + confirmReceived: BulkConfirmUtil.Normalize(request.confirm)); + + return Task.FromResult(FamilyEvolutionService.Apply(app, plan, requiredConfirm)); + } + } + + public sealed class ReadFamilyEvolutionHandler : IRequestHandler + { + public sealed class Params + { + public long instanceId { get; set; } + public string widthParameterName { get; set; } = "Width"; + public string depthParameterName { get; set; } = "Length"; + public string lineStyleName { get; set; } = "Operator - Clearance Light Dashed"; + } + + public Task Handle(UIApplication app, string jsonData) + { + var request = FamilyEvolutionService.Deserialize(jsonData); + return Task.FromResult(FamilyEvolutionService.Read(app, request)); + } + } + + internal static class FamilyEvolutionService + { + internal sealed class PreparedPlan + { + public Document ProjectDoc = null!; + public FamilyInstance Instance = null!; + public FamilySymbol SourceSymbol = null!; + public Family SourceFamily = null!; + public long InstanceId; + public long SourceFamilyId; + public long SourceTypeId; + public string InstanceUniqueId = ""; + public string Mark = ""; + public string SourceFamilyName = ""; + public string SourceTypeName = ""; + public string NewFamilyName = ""; + public string NewTypeName = ""; + public string WidthParameterName = ""; + public string DepthParameterName = ""; + public double SourceWidthFt; + public double SourceDepthFt; + public double TargetWidthFt; + public double TargetDepthFt; + public string TargetWidthDisplay = ""; + public string TargetDepthDisplay = ""; + public bool WidthIsInstance; + public bool DepthIsInstance; + public string WidthFormula = ""; + public string DepthFormula = ""; + public bool HasClearance; + public string ClearanceRequestedSide = ""; + public string ClearanceResolvedSide = ""; + public double ClearanceOffsetFt; + public string ClearanceOffsetDisplay = ""; + public string LineStyleName = ""; + public string LinePatternName = ""; + public int LineWeight; + public int Red; + public int Green; + public int Blue; + public double? ConnectorLocalX; + public double? ConnectorLocalY; + public List ClearanceSegments = new List(); + public string SavePath = ""; + public bool SavePathExists; + public string PlanHash = ""; + public string FamilyContentFingerprint = ""; + public bool ProjectFamilyNameExists; + } + + private sealed class FamilyLoadOptions : IFamilyLoadOptions + { + public bool OnFamilyFound(bool familyInUse, out bool overwriteParameterValues) + { + overwriteParameterValues = false; + return false; + } + + public bool OnSharedFamilyFound(Family sharedFamily, bool familyInUse, out FamilySource source, out bool overwriteParameterValues) + { + source = FamilySource.Family; + overwriteParameterValues = false; + return false; + } + } + + internal static T Deserialize(string jsonData) where T : new() + { + if (string.IsNullOrWhiteSpace(jsonData)) return new T(); + return JsonSerializer.Deserialize(jsonData) ?? new T(); + } + + internal static PreparedPlan Prepare(UIApplication app, FamilyEvolutionRequest request) + { + if (request.instanceId <= 0) throw new ArgumentException("instanceId is required."); + var newFamilyName = RequiredName(request.newFamilyName, "newFamilyName"); + var newTypeName = RequiredName(request.newTypeName, "newTypeName"); + var widthParameterName = RequiredName(request.widthParameterName, "widthParameterName"); + var depthParameterName = RequiredName(request.depthParameterName, "depthParameterName"); + var expectedUniqueId = RequiredName(request.expectedUniqueId, "expectedUniqueId"); + var expectedFamilyName = RequiredName(request.expectedFamilyName, "expectedFamilyName"); + var expectedTypeName = RequiredName(request.expectedTypeName, "expectedTypeName"); + + var uidoc = app.ActiveUIDocument ?? throw new InvalidOperationException("No active UI document."); + var doc = uidoc.Document; + if (doc.IsFamilyDocument) throw new InvalidOperationException("Open a project document before planning family evolution."); + var instance = doc.GetElement(ElementIdCompat.Create(request.instanceId)) as FamilyInstance + ?? throw new InvalidOperationException($"Element {request.instanceId} is not a FamilyInstance."); + var symbol = doc.GetElement(instance.GetTypeId()) as FamilySymbol + ?? throw new InvalidOperationException($"Element {request.instanceId} does not have a FamilySymbol type."); + var family = symbol.Family ?? throw new InvalidOperationException("Selected FamilySymbol has no Family."); + var mark = instance.LookupParameter("Mark")?.AsString() ?? ""; + + MatchExpected("unique id", expectedUniqueId, instance.UniqueId ?? ""); + MatchExpected("Mark", request.expectedMark, mark); + MatchExpected("family", expectedFamilyName, family.Name); + MatchExpected("type", expectedTypeName, symbol.Name); + if (string.Equals(newFamilyName, family.Name, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("newFamilyName must differ from the resolved source family so the source family is not overwritten."); + + if (!LengthTextUtil.TryParseLengthToFeet(doc, request.width, out var targetWidth, out var widthError)) + throw new ArgumentException("width is invalid: " + widthError); + if (!LengthTextUtil.TryParseLengthToFeet(doc, request.depth, out var targetDepth, out var depthError)) + throw new ArgumentException("depth is invalid: " + depthError); + if (targetWidth <= 0 || targetDepth <= 0) throw new ArgumentException("width and depth must be positive."); + + var plan = new PreparedPlan + { + ProjectDoc = doc, + Instance = instance, + SourceSymbol = symbol, + SourceFamily = family, + InstanceId = request.instanceId, + SourceFamilyId = ElementIdCompat.GetValue(family.Id), + SourceTypeId = ElementIdCompat.GetValue(symbol.Id), + InstanceUniqueId = instance.UniqueId ?? "", + Mark = mark, + SourceFamilyName = family.Name ?? "", + SourceTypeName = symbol.Name ?? "", + NewFamilyName = newFamilyName, + NewTypeName = newTypeName, + WidthParameterName = widthParameterName, + DepthParameterName = depthParameterName, + TargetWidthFt = targetWidth, + TargetDepthFt = targetDepth, + TargetWidthDisplay = request.width ?? "", + TargetDepthDisplay = request.depth ?? "" + }; + + plan.ProjectFamilyNameExists = new FilteredElementCollector(doc) + .OfClass(typeof(Family)) + .Cast() + .Any(candidate => string.Equals(candidate.Name, newFamilyName, StringComparison.OrdinalIgnoreCase)); + + plan.SavePath = ResolveSavePath(request.familySavePath, newFamilyName); + plan.SavePathExists = File.Exists(plan.SavePath); + + Document? familyDoc = null; + try + { + familyDoc = doc.EditFamily(family); + var manager = familyDoc.FamilyManager ?? throw new InvalidOperationException("Family document has no FamilyManager."); + var sourceType = FindFamilyType(manager, symbol.Name) + ?? throw new InvalidOperationException($"Family type '{symbol.Name}' was not found in the family document."); + var widthParameter = FindFamilyParameter(manager, widthParameterName) + ?? throw new InvalidOperationException($"Family parameter '{widthParameterName}' was not found."); + var depthParameter = FindFamilyParameter(manager, depthParameterName) + ?? throw new InvalidOperationException($"Family parameter '{depthParameterName}' was not found."); + + plan.WidthIsInstance = widthParameter.IsInstance; + plan.DepthIsInstance = depthParameter.IsInstance; + plan.WidthFormula = GetFormula(widthParameter); + plan.DepthFormula = GetFormula(depthParameter); + if (plan.WidthIsInstance || plan.DepthIsInstance) + throw new InvalidOperationException("Width and depth family parameters must be type parameters."); + if (plan.WidthFormula.Length > 0 || plan.DepthFormula.Length > 0) + throw new InvalidOperationException("Width and depth family parameters must not be formula-driven."); + + plan.SourceWidthFt = ReadFamilyLength(sourceType, widthParameter, widthParameterName); + plan.SourceDepthFt = ReadFamilyLength(sourceType, depthParameter, depthParameterName); + plan.FamilyContentFingerprint = ComputeFamilyContentFingerprint(familyDoc, manager); + + if (request.clearance != null) + { + PrepareClearance(instance, request.clearance, plan); + } + } + finally + { + if (familyDoc != null) + { + try { familyDoc.Close(false); } catch { } + } + } + + plan.PlanHash = ComputeHash(plan); + return plan; + } + + private static void PrepareClearance(FamilyInstance instance, FamilyEvolutionClearanceSpec spec, PreparedPlan plan) + { + plan.HasClearance = true; + plan.ClearanceRequestedSide = (spec.side ?? "").Trim().ToLowerInvariant().Replace('-', '_').Replace(' ', '_'); + if (!LengthTextUtil.TryParseLengthToFeet(plan.ProjectDoc, spec.offset, out var offsetFt, out var error) || offsetFt <= 0) + throw new ArgumentException("clearance.offset is invalid: " + error); + plan.ClearanceOffsetFt = offsetFt; + plan.ClearanceOffsetDisplay = spec.offset ?? ""; + plan.LineStyleName = RequiredName(spec.lineStyleName, "clearance.lineStyleName"); + plan.LinePatternName = (spec.linePatternName ?? "").Trim(); + plan.LineWeight = spec.lineWeight; + plan.Red = spec.red; + plan.Green = spec.green; + plan.Blue = spec.blue; + if (plan.LineWeight < 1 || plan.LineWeight > 16) throw new ArgumentException("clearance.lineWeight must be between 1 and 16."); + if (!ByteColor(plan.Red) || !ByteColor(plan.Green) || !ByteColor(plan.Blue)) + throw new ArgumentException("clearance color channels must be between 0 and 255."); + + if (plan.ClearanceRequestedSide == "power_connection") + { + var electrical = MepSystemUtil.GetConnectors(instance) + .Where(connector => connector != null && string.Equals(connector.Domain.ToString(), "DomainElectrical", StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (electrical.Count != 1) + throw new InvalidOperationException($"power_connection requires exactly one electrical connector; found {electrical.Count}."); + var local = instance.GetTransform().Inverse.OfPoint(electrical[0].Origin); + plan.ConnectorLocalX = local.X; + plan.ConnectorLocalY = local.Y; + plan.ClearanceResolvedSide = FamilyEvolutionPlan.ResolveConnectorSide(local.X, local.Y, plan.SourceWidthFt, plan.SourceDepthFt); + } + else + { + plan.ClearanceResolvedSide = FamilyEvolutionPlan.NormalizeSide(plan.ClearanceRequestedSide); + } + + plan.ClearanceSegments = FamilyEvolutionPlan.BuildClearanceRectangle( + plan.TargetWidthFt, + plan.TargetDepthFt, + plan.ClearanceOffsetFt, + plan.ClearanceResolvedSide).ToList(); + } + + internal static object ToPlanReceipt(PreparedPlan plan) + { + return new + { + status = plan.SavePathExists || plan.ProjectFamilyNameExists ? "Blocked" : "Planned", + blocker = plan.SavePathExists + ? "family_save_path_exists" + : plan.ProjectFamilyNameExists ? "project_family_name_exists" : null, + planHash = plan.PlanHash, + requiredConfirm = RequiredConfirm(plan.InstanceId), + target = new + { + instanceId = plan.InstanceId, + uniqueId = plan.InstanceUniqueId, + mark = plan.Mark, + familyId = plan.SourceFamilyId, + familyName = plan.SourceFamilyName, + typeId = plan.SourceTypeId, + typeName = plan.SourceTypeName + }, + evolution = new + { + newFamilyName = plan.NewFamilyName, + newTypeName = plan.NewTypeName, + familySavePath = plan.SavePath, + familySavePathExists = plan.SavePathExists, + width = ParameterReceipt(plan.WidthParameterName, plan.SourceWidthFt, plan.TargetWidthFt, plan.WidthFormula, plan.WidthIsInstance), + depth = ParameterReceipt(plan.DepthParameterName, plan.SourceDepthFt, plan.TargetDepthFt, plan.DepthFormula, plan.DepthIsInstance) + }, + clearance = plan.HasClearance ? ClearanceReceipt(plan) : null, + safety = new + { + sourceFamilyWillBeOverwritten = false, + projectApplyUsesSingleTransaction = true, + projectRollbackReadbackVerified = false, + recoveryArtifactRetainedOnProjectFailure = true, + selectedInstanceOnly = true + } + }; + } + + internal static object Apply(UIApplication app, PreparedPlan plan, string requiredConfirm) + { + if (plan.SavePathExists) + throw new OperatorToolUserErrorException("Family save path already exists; choose a new family name or familySavePath.", "family_save_path_exists"); + if (plan.ProjectFamilyNameExists) + throw new OperatorToolUserErrorException("A project family already has the planned new family name.", "project_family_name_exists"); + + var directory = Path.GetDirectoryName(plan.SavePath); + if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); + Document? familyDoc = null; + var createdCurveIds = new List(); + try + { + familyDoc = plan.ProjectDoc.EditFamily(plan.SourceFamily); + var manager = familyDoc.FamilyManager ?? throw new InvalidOperationException("Family document has no FamilyManager."); + var currentFingerprint = ComputeFamilyContentFingerprint(familyDoc, manager); + if (!string.Equals(currentFingerprint, plan.FamilyContentFingerprint, StringComparison.Ordinal)) + throw new InvalidOperationException("Source family content changed after planning; create a new family-evolution plan."); + var sourceType = FindFamilyType(manager, plan.SourceTypeName) + ?? throw new InvalidOperationException("Source family type is no longer present."); + if (FindFamilyType(manager, plan.NewTypeName) != null) + throw new InvalidOperationException($"Family type '{plan.NewTypeName}' already exists."); + var widthParameter = FindFamilyParameter(manager, plan.WidthParameterName) + ?? throw new InvalidOperationException("Width family parameter is no longer present."); + var depthParameter = FindFamilyParameter(manager, plan.DepthParameterName) + ?? throw new InvalidOperationException("Depth family parameter is no longer present."); + + using (var transaction = new Transaction(familyDoc, "Create selected equipment type and clearance")) + { + transaction.Start(); + WarningSuppressionUtil.SuppressWarnings(transaction); + manager.CurrentType = sourceType; + var newType = manager.NewType(plan.NewTypeName) + ?? throw new InvalidOperationException("FamilyManager.NewType did not return a family type."); + manager.Set(widthParameter, plan.TargetWidthFt); + manager.Set(depthParameter, plan.TargetDepthFt); + familyDoc.Regenerate(); + VerifyFamilyLength(newType, widthParameter, plan.TargetWidthFt, plan.WidthParameterName); + VerifyFamilyLength(newType, depthParameter, plan.TargetDepthFt, plan.DepthParameterName); + + if (plan.HasClearance) + { + var subcategory = EnsureClearanceSubcategory(familyDoc, plan); + var plane = Plane.CreateByNormalAndOrigin(XYZ.BasisZ, XYZ.Zero); + var sketchPlane = SketchPlane.Create(familyDoc, plane); + foreach (var segment in plan.ClearanceSegments) + { + var line = Line.CreateBound(new XYZ(segment.X1, segment.Y1, 0), new XYZ(segment.X2, segment.Y2, 0)); + var curve = familyDoc.FamilyCreate.NewSymbolicCurve(line, sketchPlane); + curve.Subcategory = subcategory.GetGraphicsStyle(GraphicsStyleType.Projection); + createdCurveIds.Add(ElementIdCompat.GetValue(curve.Id)); + } + familyDoc.Regenerate(); + VerifyClearanceCurves(familyDoc, createdCurveIds, subcategory, plan); + } + transaction.Commit(); + } + + familyDoc.SaveAs(plan.SavePath, new SaveAsOptions { Compact = true, OverwriteExistingFile = false }); + } + catch (Exception ex) + { + return new + { + status = "Failed", + phase = "family_edit_or_save", + error = ex.Message, + planHash = plan.PlanHash, + projectChanged = false, + recoveryArtifact = File.Exists(plan.SavePath) ? plan.SavePath : null + }; + } + finally + { + if (familyDoc != null) + { + try { familyDoc.Close(false); } catch { } + } + } + + Family? loadedFamily = null; + FamilySymbol? loadedSymbol = null; + long beforeTypeId = ElementIdCompat.GetValue(plan.Instance.GetTypeId()); + try + { + using (var transaction = new Transaction(plan.ProjectDoc, "Load selected equipment family and swap instance")) + { + transaction.Start(); + WarningSuppressionUtil.SuppressWarnings(transaction); + var loaded = plan.ProjectDoc.LoadFamily(plan.SavePath, new FamilyLoadOptions(), out loadedFamily); + if (!loaded || loadedFamily == null) + throw new InvalidOperationException("The cloned family was not loaded as a new family."); + if (!string.Equals(loadedFamily.Name, plan.NewFamilyName, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Loaded family name '{loadedFamily.Name}' did not match planned name '{plan.NewFamilyName}'."); + loadedSymbol = loadedFamily.GetFamilySymbolIds() + .Select(id => plan.ProjectDoc.GetElement(id) as FamilySymbol) + .FirstOrDefault(symbol => symbol != null && string.Equals(symbol.Name, plan.NewTypeName, StringComparison.OrdinalIgnoreCase)); + if (loadedSymbol == null) throw new InvalidOperationException("New family type was not found after loading."); + if (!loadedSymbol.IsActive) loadedSymbol.Activate(); + plan.Instance.ChangeTypeId(loadedSymbol.Id); + plan.ProjectDoc.Regenerate(); + if (ElementIdCompat.GetValue(plan.Instance.GetTypeId()) != ElementIdCompat.GetValue(loadedSymbol.Id)) + throw new InvalidOperationException("Selected instance did not retain the new family type."); + VerifyProjectLength(loadedSymbol, plan.WidthParameterName, plan.TargetWidthFt); + VerifyProjectLength(loadedSymbol, plan.DepthParameterName, plan.TargetDepthFt); + transaction.Commit(); + } + try { app.ActiveUIDocument.RefreshActiveView(); } catch { } + } + catch (Exception ex) + { + var afterTypeId = ElementIdCompat.GetValue(plan.Instance.GetTypeId()); + var loadedFamilyStillPresent = new FilteredElementCollector(plan.ProjectDoc) + .OfClass(typeof(Family)) + .Cast() + .Any(candidate => string.Equals(candidate.Name, plan.NewFamilyName, StringComparison.OrdinalIgnoreCase)); + var rollbackVerified = afterTypeId == beforeTypeId && !loadedFamilyStillPresent; + return new + { + status = "Failed", + phase = "project_load_or_swap", + error = ex.Message, + planHash = plan.PlanHash, + projectChanged = rollbackVerified ? (bool?)false : null, + rollback = new { verified = rollbackVerified, beforeTypeId, afterTypeId, clonedFamilyAbsent = !loadedFamilyStillPresent }, + recoveryArtifact = plan.SavePath, + recoveryAction = "Inspect or load the retained RFA manually; the project transaction was rolled back." + }; + } + + return new + { + status = "Applied", + planHash = plan.PlanHash, + requiredConfirm, + instanceId = plan.InstanceId, + beforeTypeId, + familyId = ElementIdCompat.GetValue(loadedFamily?.Id), + familyName = loadedFamily?.Name, + typeId = ElementIdCompat.GetValue(loadedSymbol?.Id), + typeName = loadedSymbol?.Name, + width = ParameterReceipt(plan.WidthParameterName, plan.SourceWidthFt, plan.TargetWidthFt, plan.WidthFormula, plan.WidthIsInstance), + depth = ParameterReceipt(plan.DepthParameterName, plan.SourceDepthFt, plan.TargetDepthFt, plan.DepthFormula, plan.DepthIsInstance), + clearance = plan.HasClearance ? ClearanceReceipt(plan) : null, + createdFamilyCurveIds = createdCurveIds, + familyArtifact = plan.SavePath, + projectChanged = true, + recovery = new { sourceFamilyId = plan.SourceFamilyId, sourceTypeId = plan.SourceTypeId, retainedArtifact = plan.SavePath } + }; + } + + internal static object Read(UIApplication app, ReadFamilyEvolutionHandler.Params request) + { + if (request.instanceId <= 0) throw new ArgumentException("instanceId is required."); + var uidoc = app.ActiveUIDocument ?? throw new InvalidOperationException("No active UI document."); + var doc = uidoc.Document; + var instance = doc.GetElement(ElementIdCompat.Create(request.instanceId)) as FamilyInstance + ?? throw new InvalidOperationException($"Element {request.instanceId} is not a FamilyInstance."); + var symbol = doc.GetElement(instance.GetTypeId()) as FamilySymbol + ?? throw new InvalidOperationException("Selected instance has no FamilySymbol."); + var family = symbol.Family ?? throw new InvalidOperationException("Selected symbol has no Family."); + var projectWidth = ReadProjectLength(symbol, request.widthParameterName); + var projectDepth = ReadProjectLength(symbol, request.depthParameterName); + var curves = new List(); + long? linePatternId = null; + string? linePatternName = null; + int? lineWeight = null; + int[]? lineColor = null; + Document? familyDoc = null; + try + { + familyDoc = doc.EditFamily(family); + foreach (var curve in new FilteredElementCollector(familyDoc).OfClass(typeof(CurveElement)).Cast()) + { + if (curve.CurveElementType != CurveElementType.SymbolicCurve) continue; + var graphicsStyle = curve.LineStyle as GraphicsStyle; + var subcategory = graphicsStyle?.GraphicsStyleCategory; + if (subcategory == null || !string.Equals(subcategory.Name, request.lineStyleName, StringComparison.OrdinalIgnoreCase)) continue; + if (!linePatternId.HasValue) + { + try + { + var patternId = subcategory.GetLinePatternId(GraphicsStyleType.Projection); + linePatternId = ElementIdCompat.GetValue(patternId); + linePatternName = (familyDoc.GetElement(patternId) as LinePatternElement)?.Name; + } + catch { } + try { lineWeight = subcategory.GetLineWeight(GraphicsStyleType.Projection); } catch { } + try + { + var color = subcategory.LineColor; + lineColor = new[] { (int)color.Red, (int)color.Green, (int)color.Blue }; + } + catch { } + } + var geometry = curve.GeometryCurve; + var start = geometry?.GetEndPoint(0); + var end = geometry?.GetEndPoint(1); + curves.Add(new + { + id = ElementIdCompat.GetValue(curve.Id), + style = subcategory.Name, + start = start == null ? null : new[] { start.X, start.Y, start.Z }, + end = end == null ? null : new[] { end.X, end.Y, end.Z } + }); + } + } + finally + { + if (familyDoc != null) + { + try { familyDoc.Close(false); } catch { } + } + } + + return new + { + status = "Read", + instanceId = request.instanceId, + uniqueId = instance.UniqueId, + mark = instance.LookupParameter("Mark")?.AsString(), + familyId = ElementIdCompat.GetValue(family.Id), + familyName = family.Name, + typeId = ElementIdCompat.GetValue(symbol.Id), + typeName = symbol.Name, + width = new { parameterName = request.widthParameterName, internalFeet = projectWidth }, + depth = new { parameterName = request.depthParameterName, internalFeet = projectDepth }, + clearance = new + { + lineStyleName = request.lineStyleName, + linePatternId, + linePatternName, + lineWeight, + color = lineColor, + symbolicCurveCount = curves.Count, + symbolicCurves = curves + } + }; + } + + internal static string RequiredConfirm(long instanceId) => $"APPLY FAMILY EVOLUTION {instanceId}"; + + private static string ComputeHash(PreparedPlan plan) + { + var fields = new List> + { + Field("instanceId", plan.InstanceId.ToString(CultureInfo.InvariantCulture)), + Field("instanceUniqueId", plan.InstanceUniqueId), + Field("sourceFamilyId", plan.SourceFamilyId.ToString(CultureInfo.InvariantCulture)), + Field("sourceTypeId", plan.SourceTypeId.ToString(CultureInfo.InvariantCulture)), + Field("sourceFamilyName", plan.SourceFamilyName), + Field("sourceTypeName", plan.SourceTypeName), + Field("familyContentFingerprint", plan.FamilyContentFingerprint), + Field("mark", plan.Mark), + Field("newFamilyName", plan.NewFamilyName), + Field("newTypeName", plan.NewTypeName), + Field("widthParameter", plan.WidthParameterName), + Field("depthParameter", plan.DepthParameterName), + Field("sourceWidthFt", FamilyEvolutionPlan.CanonicalNumber(plan.SourceWidthFt)), + Field("sourceDepthFt", FamilyEvolutionPlan.CanonicalNumber(plan.SourceDepthFt)), + Field("targetWidthFt", FamilyEvolutionPlan.CanonicalNumber(plan.TargetWidthFt)), + Field("targetDepthFt", FamilyEvolutionPlan.CanonicalNumber(plan.TargetDepthFt)), + Field("savePath", plan.SavePath), + Field("projectFamilyNameExists", plan.ProjectFamilyNameExists ? "true" : "false"), + Field("hasClearance", plan.HasClearance ? "true" : "false") + }; + if (plan.HasClearance) + { + fields.Add(Field("clearanceRequestedSide", plan.ClearanceRequestedSide)); + fields.Add(Field("clearanceResolvedSide", plan.ClearanceResolvedSide)); + fields.Add(Field("clearanceOffsetFt", FamilyEvolutionPlan.CanonicalNumber(plan.ClearanceOffsetFt))); + fields.Add(Field("lineStyleName", plan.LineStyleName)); + fields.Add(Field("linePatternName", plan.LinePatternName)); + fields.Add(Field("lineWeight", plan.LineWeight.ToString(CultureInfo.InvariantCulture))); + fields.Add(Field("lineColor", $"{plan.Red},{plan.Green},{plan.Blue}")); + fields.Add(Field("connectorLocalX", plan.ConnectorLocalX.HasValue ? FamilyEvolutionPlan.CanonicalNumber(plan.ConnectorLocalX.Value) : "")); + fields.Add(Field("connectorLocalY", plan.ConnectorLocalY.HasValue ? FamilyEvolutionPlan.CanonicalNumber(plan.ConnectorLocalY.Value) : "")); + } + return FamilyEvolutionPlan.ComputePlanHash(fields); + } + + private static string ComputeFamilyContentFingerprint(Document familyDoc, FamilyManager manager) + { + var fields = new List>(); + foreach (var familyType in manager.Types.Cast().OrderBy(type => type.Name, StringComparer.OrdinalIgnoreCase)) + { + foreach (var parameter in manager.Parameters.Cast() + .OrderBy(item => item.Definition?.Name ?? "", StringComparer.OrdinalIgnoreCase)) + { + var parameterName = parameter.Definition?.Name ?? ""; + var value = ""; + try + { + switch (parameter.StorageType) + { + case StorageType.Double: + value = familyType.AsDouble(parameter).HasValue + ? FamilyEvolutionPlan.CanonicalNumber(familyType.AsDouble(parameter)!.Value) + : ""; + break; + case StorageType.Integer: + value = familyType.AsInteger(parameter)?.ToString(CultureInfo.InvariantCulture) ?? ""; + break; + case StorageType.ElementId: + var id = familyType.AsElementId(parameter); + value = id == null ? "" : ElementIdCompat.GetValue(id).ToString(CultureInfo.InvariantCulture); + break; + case StorageType.String: + value = familyType.AsString(parameter) ?? ""; + break; + } + } + catch { value = ""; } + fields.Add(Field( + $"type:{familyType.Name}:parameter:{parameterName}", + $"{parameter.StorageType}|instance={parameter.IsInstance}|formula={GetFormula(parameter)}|value={value}")); + } + } + + foreach (var element in new FilteredElementCollector(familyDoc) + .WhereElementIsNotElementType() + .OrderBy(element => ElementIdCompat.GetValue(element.Id))) + { + var id = ElementIdCompat.GetValue(element.Id); + var category = element.Category?.Name ?? ""; + var name = ""; + try { name = element.Name ?? ""; } catch { } + var detail = $"{element.GetType().FullName}|{category}|{name}"; + if (element is CurveElement curve) + { + var geometry = curve.GeometryCurve; + if (geometry != null) + { + detail += "|curve=" + PointKey(geometry.GetEndPoint(0)) + ">" + PointKey(geometry.GetEndPoint(1)); + } + } + else if (element is ReferencePlane referencePlane) + { + detail += "|reference=" + PointKey(referencePlane.BubbleEnd) + ">" + PointKey(referencePlane.FreeEnd); + } + fields.Add(Field("element:" + id.ToString(CultureInfo.InvariantCulture), detail)); + } + + if (fields.Count == 0) fields.Add(Field("family", familyDoc.Title ?? "")); + return FamilyEvolutionPlan.ComputePlanHash(fields); + } + + private static void VerifyClearanceCurves( + Document familyDoc, + IReadOnlyCollection createdCurveIds, + Category subcategory, + PreparedPlan plan) + { + if (createdCurveIds.Count != plan.ClearanceSegments.Count) + throw new InvalidOperationException("Clearance curve count did not match the planned rectangle."); + + var actual = new List(); + foreach (var id in createdCurveIds) + { + var curve = familyDoc.GetElement(ElementIdCompat.Create(id)) as CurveElement + ?? throw new InvalidOperationException("A created clearance curve could not be read back."); + if (curve.CurveElementType != CurveElementType.SymbolicCurve) + throw new InvalidOperationException("A created clearance curve was not symbolic."); + var style = (curve.LineStyle as GraphicsStyle)?.GraphicsStyleCategory; + if (style == null || !string.Equals(style.Name, plan.LineStyleName, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("A created clearance curve did not retain the planned line style."); + var geometry = curve.GeometryCurve; + if (geometry == null) throw new InvalidOperationException("A created clearance curve has no geometry."); + var start = geometry.GetEndPoint(0); + var end = geometry.GetEndPoint(1); + actual.Add(new ClearanceSegment(start.X, start.Y, end.X, end.Y)); + } + + foreach (var expected in plan.ClearanceSegments) + { + var index = actual.FindIndex(candidate => SameSegment(candidate, expected)); + if (index < 0) throw new InvalidOperationException("Created clearance geometry did not match the planned rectangle."); + actual.RemoveAt(index); + } + + if (subcategory.GetLineWeight(GraphicsStyleType.Projection) != plan.LineWeight) + throw new InvalidOperationException("Clearance line weight readback did not match the plan."); + var color = subcategory.LineColor; + if (color.Red != plan.Red || color.Green != plan.Green || color.Blue != plan.Blue) + throw new InvalidOperationException("Clearance line color readback did not match the plan."); + if (plan.LinePatternName.Length > 0) + { + var patternId = subcategory.GetLinePatternId(GraphicsStyleType.Projection); + var patternName = (familyDoc.GetElement(patternId) as LinePatternElement)?.Name ?? ""; + if (!string.Equals(patternName, plan.LinePatternName, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Clearance line pattern readback did not match the plan."); + } + } + + private static bool SameSegment(ClearanceSegment left, ClearanceSegment right) => + SamePoint(left.X1, left.Y1, right.X1, right.Y1) && SamePoint(left.X2, left.Y2, right.X2, right.Y2) || + SamePoint(left.X1, left.Y1, right.X2, right.Y2) && SamePoint(left.X2, left.Y2, right.X1, right.Y1); + + private static bool SamePoint(double x1, double y1, double x2, double y2) => + Math.Abs(x1 - x2) <= 1e-7 && Math.Abs(y1 - y2) <= 1e-7; + + private static string PointKey(XYZ point) => string.Join(",", new[] + { + FamilyEvolutionPlan.CanonicalNumber(point.X), + FamilyEvolutionPlan.CanonicalNumber(point.Y), + FamilyEvolutionPlan.CanonicalNumber(point.Z) + }); + + private static Category EnsureClearanceSubcategory(Document familyDoc, PreparedPlan plan) + { + var parent = familyDoc.OwnerFamily?.FamilyCategory + ?? throw new InvalidOperationException("Family category is unavailable."); + Category? subcategory = null; + foreach (Category candidate in parent.SubCategories) + { + if (candidate != null && string.Equals(candidate.Name, plan.LineStyleName, StringComparison.OrdinalIgnoreCase)) + { + subcategory = candidate; + break; + } + } + if (subcategory == null) + subcategory = familyDoc.Settings.Categories.NewSubcategory(parent, plan.LineStyleName); + subcategory.SetLineWeight(plan.LineWeight, GraphicsStyleType.Projection); + subcategory.LineColor = new Color((byte)plan.Red, (byte)plan.Green, (byte)plan.Blue); + + if (plan.LinePatternName.Length > 0) + { + var pattern = new FilteredElementCollector(familyDoc) + .OfClass(typeof(LinePatternElement)) + .Cast() + .FirstOrDefault(element => string.Equals(element.Name, plan.LinePatternName, StringComparison.OrdinalIgnoreCase)); + if (pattern == null) + { + var definition = new LinePattern(plan.LinePatternName); + definition.SetSegments(new List + { + new LinePatternSegment(LinePatternSegmentType.Dash, 0.5), + new LinePatternSegment(LinePatternSegmentType.Space, 0.25) + }); + pattern = LinePatternElement.Create(familyDoc, definition); + } + var method = subcategory.GetType().GetMethod( + "SetLinePatternId", + BindingFlags.Public | BindingFlags.Instance, + null, + new[] { typeof(ElementId), typeof(GraphicsStyleType) }, + null); + if (method == null) + throw new InvalidOperationException("This Revit version cannot assign the requested dashed line pattern."); + method.Invoke(subcategory, new object[] { pattern.Id, GraphicsStyleType.Projection }); + } + return subcategory; + } + + private static FamilyType? FindFamilyType(FamilyManager manager, string name) => + manager.Types.Cast().FirstOrDefault(type => string.Equals(type.Name, name, StringComparison.OrdinalIgnoreCase)); + + private static FamilyParameter? FindFamilyParameter(FamilyManager manager, string name) => + manager.Parameters.Cast().FirstOrDefault(parameter => string.Equals(parameter.Definition?.Name, name, StringComparison.OrdinalIgnoreCase)); + + private static string GetFormula(FamilyParameter parameter) + { + try + { + var property = parameter.GetType().GetProperty("Formula", BindingFlags.Public | BindingFlags.Instance); + return (property?.GetValue(parameter, null) as string ?? "").Trim(); + } + catch { return ""; } + } + + private static double ReadFamilyLength(FamilyType type, FamilyParameter parameter, string name) + { + try + { + var value = type.AsDouble(parameter); + if (!value.HasValue || value.Value <= 0) throw new InvalidOperationException(); + return value.Value; + } + catch + { + throw new InvalidOperationException($"Family parameter '{name}' is not a positive length value on the source type."); + } + } + + private static void VerifyFamilyLength(FamilyType type, FamilyParameter parameter, double expected, string name) + { + var actual = ReadFamilyLength(type, parameter, name); + if (Math.Abs(actual - expected) > 1e-7) + throw new InvalidOperationException($"Family parameter '{name}' readback did not match the requested value."); + } + + private static double ReadProjectLength(FamilySymbol symbol, string parameterName) + { + var parameter = symbol.LookupParameter(parameterName) + ?? throw new InvalidOperationException($"Project type parameter '{parameterName}' was not found."); + if (parameter.StorageType != StorageType.Double) + throw new InvalidOperationException($"Project type parameter '{parameterName}' is not a length value."); + return parameter.AsDouble(); + } + + private static void VerifyProjectLength(FamilySymbol symbol, string parameterName, double expected) + { + var actual = ReadProjectLength(symbol, parameterName); + if (Math.Abs(actual - expected) > 1e-7) + throw new InvalidOperationException($"Loaded project type parameter '{parameterName}' did not match the requested value."); + } + + private static object ParameterReceipt(string name, double before, double after, string formula, bool isInstance) => new + { + parameterName = name, + beforeInternalFeet = before, + afterInternalFeet = after, + formula, + isInstance, + writableTypeParameter = !isInstance && string.IsNullOrWhiteSpace(formula) + }; + + private static object ClearanceReceipt(PreparedPlan plan) => new + { + requestedSide = plan.ClearanceRequestedSide, + resolvedSide = plan.ClearanceResolvedSide, + offsetInternalFeet = plan.ClearanceOffsetFt, + offsetDisplay = plan.ClearanceOffsetDisplay, + connectorLocal = plan.ConnectorLocalX.HasValue ? new[] { plan.ConnectorLocalX.Value, plan.ConnectorLocalY.GetValueOrDefault() } : null, + representation = "family_symbolic_plan", + scope = "cloned_family", + lineStyleName = plan.LineStyleName, + linePatternName = plan.LinePatternName, + lineWeight = plan.LineWeight, + color = new[] { plan.Red, plan.Green, plan.Blue }, + segments = plan.ClearanceSegments.Select(segment => new[] { segment.X1, segment.Y1, segment.X2, segment.Y2 }).ToList() + }; + + private static string ResolveSavePath(string? requested, string newFamilyName) + { + if (!string.IsNullOrWhiteSpace(requested)) return WorkspacePaths.ResolveFileUnderWorkspace(requested); + var invalid = Path.GetInvalidFileNameChars(); + var safe = new string(newFamilyName.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); + if (safe.Length == 0) throw new ArgumentException("newFamilyName cannot produce an empty file name."); + return WorkspacePaths.ResolveFileUnderWorkspace(Path.Combine("FamilyEvolution", safe + ".rfa")); + } + + private static string RequiredName(string? value, string field) + { + var normalized = (value ?? "").Trim(); + if (normalized.Length == 0) throw new ArgumentException(field + " is required."); + return normalized; + } + + private static void MatchExpected(string label, string? expected, string actual) + { + if (string.IsNullOrWhiteSpace(expected)) return; + if (!string.Equals(expected.Trim(), actual ?? "", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Expected {label} '{expected.Trim()}', but found '{actual}'."); + } + + private static KeyValuePair Field(string key, string value) => new KeyValuePair(key, value ?? ""); + private static bool ByteColor(int value) => value >= 0 && value <= 255; + } +} diff --git a/apps/revit-bridge-addin/RevitBridge.Logic/LogicService.cs b/apps/revit-bridge-addin/RevitBridge.Logic/LogicService.cs index a8c4f96..e13567b 100644 --- a/apps/revit-bridge-addin/RevitBridge.Logic/LogicService.cs +++ b/apps/revit-bridge-addin/RevitBridge.Logic/LogicService.cs @@ -63,6 +63,9 @@ public LogicService() { "/revit/create-similar-from-instance", new CreateSimilarFromInstanceHandler() }, { "/revit/load-family", new LoadFamilyHandler() }, { "/revit/create-family-from-template", new CreateFamilyFromTemplateHandler() }, + { "/revit/plan-family-evolution", new PlanFamilyEvolutionHandler() }, + { "/revit/apply-family-evolution", new ApplyFamilyEvolutionHandler() }, + { "/revit/read-family-evolution", new ReadFamilyEvolutionHandler() }, { "/revit/tag-elements", new TagElementsHandler() }, { "/revit/create-schedule", new CreateScheduleHandler() }, { "/revit/spatial-analysis", new SpatialAnalysisHandler() }, diff --git a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionAllowlist.cs b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionAllowlist.cs index 597ab71..3a8f94b 100644 --- a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionAllowlist.cs +++ b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionAllowlist.cs @@ -134,6 +134,9 @@ public AllowlistEntry(string method, string path) "/revit/find-family-text-notes", "/revit/set-text-note-text", "/revit/reload-family-edit-session", + "/revit/plan-family-evolution", + "/revit/apply-family-evolution", + "/revit/read-family-evolution", "/revit/measure-gap", "/revit/transaction-plan", "/revit/transaction-validate", diff --git a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionRunner.cs b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionRunner.cs index f2a8741..4e9ab35 100644 --- a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionRunner.cs +++ b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionRunner.cs @@ -227,7 +227,10 @@ public OperatorActionRunner(RevitEventService eventService) { "/revit/resolve-element-type", new ResolveElementTypeHandler() }, { "/revit/duplicate-element-type", new DuplicateElementTypeHandler() }, { "/revit/set-type-parameters", new SetTypeParametersHandler() }, - { "/revit/duplicate-type-and-swap-instance", new DuplicateTypeAndSwapInstanceHandler() } + { "/revit/duplicate-type-and-swap-instance", new DuplicateTypeAndSwapInstanceHandler() }, + { "/revit/plan-family-evolution", new RevitBridge.Logic.Handlers.PlanFamilyEvolutionHandler() }, + { "/revit/apply-family-evolution", new RevitBridge.Logic.Handlers.ApplyFamilyEvolutionHandler() }, + { "/revit/read-family-evolution", new RevitBridge.Logic.Handlers.ReadFamilyEvolutionHandler() } }; } diff --git a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionSchemaValidator.cs b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionSchemaValidator.cs index bbc00e5..1823fd8 100644 --- a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionSchemaValidator.cs +++ b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorActionSchemaValidator.cs @@ -6722,6 +6722,54 @@ private static bool TryValidate(OperatorActionCall action, out string? error, ou return true; } + if (string.Equals(path, "/revit/plan-family-evolution", StringComparison.OrdinalIgnoreCase) || + string.Equals(path, "/revit/apply-family-evolution", StringComparison.OrdinalIgnoreCase)) + { + if (!IsNullOrObject(body, out var obj) || !obj.HasValue) + { + error = "family evolution body must be an object."; + return false; + } + if (!ValidateRequiredLong(obj.Value, "instanceId", out error)) return false; + if (!ValidateRequiredString(obj.Value, "expectedUniqueId", maxLen: 256, out error)) return false; + if (!ValidateRequiredString(obj.Value, "expectedFamilyName", maxLen: 128, out error)) return false; + if (!ValidateRequiredString(obj.Value, "expectedTypeName", maxLen: 128, out error)) return false; + if (!ValidateRequiredString(obj.Value, "newFamilyName", maxLen: 128, out error)) return false; + if (!ValidateRequiredString(obj.Value, "newTypeName", maxLen: 128, out error)) return false; + if (!ValidateRequiredString(obj.Value, "width", maxLen: 64, out error)) return false; + if (!ValidateRequiredString(obj.Value, "depth", maxLen: 64, out error)) return false; + if (!ValidateOptionalString(obj.Value, "expectedMark", maxLen: 128, out error)) return false; + if (!ValidateOptionalString(obj.Value, "widthParameterName", maxLen: 128, out error)) return false; + if (!ValidateOptionalString(obj.Value, "depthParameterName", maxLen: 128, out error)) return false; + if (!ValidateOptionalString(obj.Value, "familySavePath", maxLen: 1024, out error)) return false; + if (obj.Value.TryGetProperty("clearance", out var clearance) && + clearance.ValueKind != JsonValueKind.Null && clearance.ValueKind != JsonValueKind.Object) + { + error = "family evolution clearance must be an object."; + return false; + } + if (string.Equals(path, "/revit/apply-family-evolution", StringComparison.OrdinalIgnoreCase)) + { + if (!ValidateRequiredString(obj.Value, "planHash", maxLen: 128, out error)) return false; + if (!ValidateRequiredString(obj.Value, "confirm", maxLen: 128, out error)) return false; + } + return true; + } + + if (string.Equals(path, "/revit/read-family-evolution", StringComparison.OrdinalIgnoreCase)) + { + if (!IsNullOrObject(body, out var obj) || !obj.HasValue) + { + error = "read-family-evolution body must be an object."; + return false; + } + if (!ValidateRequiredLong(obj.Value, "instanceId", out error)) return false; + if (!ValidateOptionalString(obj.Value, "widthParameterName", maxLen: 128, out error)) return false; + if (!ValidateOptionalString(obj.Value, "depthParameterName", maxLen: 128, out error)) return false; + if (!ValidateOptionalString(obj.Value, "lineStyleName", maxLen: 128, out error)) return false; + return true; + } + if (string.Equals(path, "/revit/duplicate-type-and-swap-instance", StringComparison.OrdinalIgnoreCase)) { // { instanceId:number, newTypeName:string, typeParamChanges?:[{parameterName,value}], dryRun?:bool, confirm?:string } diff --git a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorApprovalPolicy.cs b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorApprovalPolicy.cs index 9bdb6d8..fa15a97 100644 --- a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorApprovalPolicy.cs +++ b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorApprovalPolicy.cs @@ -136,6 +136,9 @@ public static OperatorActionRisk GetRisk(string method, string path) if (string.Equals(p, "/revit/duplicate-element-type", StringComparison.OrdinalIgnoreCase)) return OperatorActionRisk.High; if (string.Equals(p, "/revit/set-type-parameters", StringComparison.OrdinalIgnoreCase)) return OperatorActionRisk.High; if (string.Equals(p, "/revit/duplicate-type-and-swap-instance", StringComparison.OrdinalIgnoreCase)) return OperatorActionRisk.High; + if (string.Equals(p, "/revit/plan-family-evolution", StringComparison.OrdinalIgnoreCase)) return OperatorActionRisk.Low; + if (string.Equals(p, "/revit/apply-family-evolution", StringComparison.OrdinalIgnoreCase)) return OperatorActionRisk.High; + if (string.Equals(p, "/revit/read-family-evolution", StringComparison.OrdinalIgnoreCase)) return OperatorActionRisk.Low; if (string.Equals(p, "/revit/set-selection", StringComparison.OrdinalIgnoreCase)) return OperatorActionRisk.Low; if (string.Equals(p, "/revit/resolve-room-plan-view", StringComparison.OrdinalIgnoreCase)) return OperatorActionRisk.Low; if (string.Equals(p, "/revit/plan-dwelling-receptacles", StringComparison.OrdinalIgnoreCase)) return OperatorActionRisk.Low; diff --git a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolIntrospection.cs b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolIntrospection.cs index 73d98fa..1d62f77 100644 --- a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolIntrospection.cs +++ b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolIntrospection.cs @@ -449,6 +449,9 @@ private static class ToolSchemaBuilder { "/revit/duplicate-element-type", typeof(RevitBridge.Logic.Handlers.DuplicateElementTypeHandler.Params) }, { "/revit/set-type-parameters", typeof(RevitBridge.Logic.Handlers.SetTypeParametersHandler.Params) }, { "/revit/duplicate-type-and-swap-instance", typeof(RevitBridge.Logic.Handlers.DuplicateTypeAndSwapInstanceHandler.Params) }, + { "/revit/plan-family-evolution", typeof(RevitBridge.Logic.Handlers.PlanFamilyEvolutionHandler.Params) }, + { "/revit/apply-family-evolution", typeof(RevitBridge.Logic.Handlers.ApplyFamilyEvolutionHandler.Params) }, + { "/revit/read-family-evolution", typeof(RevitBridge.Logic.Handlers.ReadFamilyEvolutionHandler.Params) }, { "/revit/replace-door", typeof(RevitBridge.Logic.Handlers.ReplaceDoorHandler.Params) }, { "/revit/titleblock-label-map", typeof(RevitBridge.Logic.Handlers.TitleblockLabelMapHandler.Params) }, { "/revit/capture-sheet-region", typeof(RevitBridge.Logic.Handlers.CaptureSheetRegionHandler.Params) }, diff --git a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs index 3066aa5..e318a7e 100644 --- a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs +++ b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs @@ -108,6 +108,9 @@ internal static class OperatorToolManifest new OperatorToolInfo("MEP", "POST", "/revit/repair-duct-continuity-by-scope", "Repair Duct Continuity By Scope", OperatorActionRisk.High, "Repair continuity breaks in room/plenum ductwork without changing target size. Audits open connectors + disconnected touched IDs, attempts best-effort reconnect/bridge repairs when dryRun=false, then audits created repair segments for isolation/system mismatch. Body: {scope:{roomNumber,verticalScope?:\"room\"|\"plenum\"|\"room+plenum\",roomMode?:\"auto\"|\"roomAware\"|\"geometry\"},systemClassification?,includeTerminals?,verify?,dryRun?,maxGapFt?,maxRepairs?,maxElements?}.", "dry-run repair duct continuity in office unit 301"), new OperatorToolInfo("MEP", "POST", "/revit/get-connectors", "Get Connectors", OperatorActionRisk.Low, "Get connector origins/sizes/directions for elements (ducts, fittings, terminals, equipment). Body: {elementIds:[...], includeAllRefs?, includeCoordinateSystem?, maxConnectorsPerElement?}.", "get connectors for these elements"), new OperatorToolInfo("MEP", "POST", "/revit/duplicate-type-and-swap-instance", "Duplicate Type + Swap Instance", OperatorActionRisk.High, "Duplicate the current type of an element instance, apply optional type parameter changes on the duplicated type, then swap only that instance to the new type. Useful for type-driven terminals/fittings. Body: {instanceId,newTypeName,typeParamChanges?,dryRun?,confirm?}.", "duplicate this terminal type to 6\\\" and swap only this instance"), + new OperatorToolInfo("Modeling", "POST", "/revit/plan-family-evolution", "Plan Family Evolution", OperatorActionRisk.Low, "Read-only, fail-closed plan for cloning a selected loaded family, creating a type with exact width/depth, and optionally adding a light symbolic clearance rectangle. Requires immutable instance/family/type identity, fingerprints family content, and resolves side=power_connection only from one electrical connector close to an equipment edge. Body: {instanceId,expectedUniqueId,expectedMark?,expectedFamilyName,expectedTypeName,newFamilyName,newTypeName,widthParameterName?,depthParameterName?,width,depth,clearance?:{side,offset,lineStyleName?,linePatternName?,lineWeight?,red?,green?,blue?},familySavePath?}.", "plan HRU403 as 3 feet 8 inches by 4 feet 2 inches with 36 inch power-side clearance"), + new OperatorToolInfo("Modeling", "POST", "/revit/apply-family-evolution", "Apply Family Evolution", OperatorActionRisk.High, "Apply an exact plan-family-evolution receipt. Requires the same request, planHash, and typed confirmation. Clones the family into the guarded workspace, edits the new type and symbolic clearance atomically, loads it, and swaps only the selected instance. The source family is never overwritten.", "apply the approved HRU403 family evolution plan"), + new OperatorToolInfo("Query", "POST", "/revit/read-family-evolution", "Read Family Evolution", OperatorActionRisk.Low, "Read back the selected instance family/type, exact width/depth type values, and symbolic clearance curves by line style. Body: {instanceId,widthParameterName?,depthParameterName?,lineStyleName?}.", "read back HRU403 family size and clearance"), new OperatorToolInfo("Query", "POST", "/revit/query", "Query", OperatorActionRisk.Low, "Read-only element query.", "query doors on Level 1"), new OperatorToolInfo("Query", "POST", "/revit/resolve", "Resolve", OperatorActionRisk.Low, "Read-only resolve helpers.", "resolve view \"Level 1\""), new OperatorToolInfo("Query", "POST", "/revit/get-element-summary", "Element Summary", OperatorActionRisk.Low, "Read-only element summaries.", "summarize selected elements"), diff --git a/apps/revit-bridge-addin/RevitBridge/Server/RevitHttpServer.cs b/apps/revit-bridge-addin/RevitBridge/Server/RevitHttpServer.cs index 117040d..2bf42fe 100644 --- a/apps/revit-bridge-addin/RevitBridge/Server/RevitHttpServer.cs +++ b/apps/revit-bridge-addin/RevitBridge/Server/RevitHttpServer.cs @@ -233,6 +233,9 @@ public RevitHttpServer(RevitEventService eventService) { "/revit/duplicate-element-type", new DuplicateElementTypeHandler() }, { "/revit/set-type-parameters", new SetTypeParametersHandler() }, { "/revit/duplicate-type-and-swap-instance", new DuplicateTypeAndSwapInstanceHandler() }, + { "/revit/plan-family-evolution", new RevitBridge.Logic.Handlers.PlanFamilyEvolutionHandler() }, + { "/revit/apply-family-evolution", new RevitBridge.Logic.Handlers.ApplyFamilyEvolutionHandler() }, + { "/revit/read-family-evolution", new RevitBridge.Logic.Handlers.ReadFamilyEvolutionHandler() }, { "/revit/replace-door", new ReplaceDoorHandler() }, // EPIC-0010: Safe move primitive + region-based capture diff --git a/apps/revit-bridge-addin/RevitBridge/Tooling/tool_examples.json b/apps/revit-bridge-addin/RevitBridge/Tooling/tool_examples.json index 25106a7..53b6880 100644 --- a/apps/revit-bridge-addin/RevitBridge/Tooling/tool_examples.json +++ b/apps/revit-bridge-addin/RevitBridge/Tooling/tool_examples.json @@ -2510,6 +2510,88 @@ } ] }, + { + "method": "POST", + "path": "/revit/plan-family-evolution", + "examples": [ + { + "name": "Plan HRU403 selected-equipment size and power-side clearance", + "request": { + "instanceId": 1465049, + "expectedUniqueId": "copy exact uniqueId from the selected element receipt", + "expectedMark": "HRU403", + "expectedFamilyName": "HeatRecoveryUnit", + "expectedTypeName": "Heat Recovery Unit (HRU)", + "newFamilyName": "HeatRecoveryUnit - HRU403 Selected", + "newTypeName": "HRU403 - 3ft8 x 4ft2", + "width": "3' 8\"", + "depth": "4' 2\"", + "clearance": { + "side": "power_connection", + "offset": "36 in", + "lineStyleName": "Operator - Clearance Light Dashed", + "linePatternName": "Operator - Clearance Dash" + } + }, + "expected_response_shape": { + "status": "Planned", + "planHash": "sha256", + "requiredConfirm": "APPLY FAMILY EVOLUTION 1465049" + } + } + ] + }, + { + "method": "POST", + "path": "/revit/apply-family-evolution", + "examples": [ + { + "name": "Apply exact approved family evolution plan", + "request": { + "instanceId": 1465049, + "expectedUniqueId": "copy exact uniqueId from the plan target", + "expectedMark": "HRU403", + "expectedFamilyName": "HeatRecoveryUnit", + "expectedTypeName": "Heat Recovery Unit (HRU)", + "newFamilyName": "HeatRecoveryUnit - HRU403 Selected", + "newTypeName": "HRU403 - 3ft8 x 4ft2", + "width": "3' 8\"", + "depth": "4' 2\"", + "clearance": { + "side": "power_connection", + "offset": "36 in" + }, + "planHash": "exact hash from plan", + "confirm": "APPLY FAMILY EVOLUTION 1465049" + }, + "expected_response_shape": { + "status": "Applied", + "instanceId": 1465049, + "familyArtifact": "workspace rfa path" + } + } + ] + }, + { + "method": "POST", + "path": "/revit/read-family-evolution", + "examples": [ + { + "name": "Read back evolved HRU family", + "request": { + "instanceId": 1465049, + "lineStyleName": "Operator - Clearance Light Dashed" + }, + "expected_response_shape": { + "status": "Read", + "instanceId": 1465049, + "clearance": { + "symbolicCurveCount": 4 + } + } + } + ] + }, { "method": "POST", "path": "/revit/query",