diff --git a/apps/operator-backend/src/deterministic/room_receptacle_analog.ts b/apps/operator-backend/src/deterministic/room_receptacle_analog.ts index 514595b..5e5600b 100644 --- a/apps/operator-backend/src/deterministic/room_receptacle_analog.ts +++ b/apps/operator-backend/src/deterministic/room_receptacle_analog.ts @@ -5,6 +5,20 @@ import { appendGoalProgress, getActiveGoalForSession, setAgentGoal, updateGoal } const PLAN_PATH = "/revit/plan-room-receptacles-from-analog"; const APPLY_PATH = "/revit/apply-room-receptacles-from-analog"; +const MATCH_SOURCE_CIRCUIT_MODE = "match_source_system"; + +function requestedCircuitMode(intent: AecTaskIntentV1 | null): "none" | "match_source_system" { + const text = intent?.evidence.user_text ?? ""; + const explicitCircuitMatch = /\b(?:same|match|copy|mirror|include|assign)\b.{0,48}\b(?:circuits?|panels?)\b/i.test(text) + || /\b(?:circuits?|panels?)\b.{0,48}\b(?:same|match|copy|mirror|include|assign)\b/i.test(text) + || /\bwire(?:d|ing)?\b/i.test(text); + return explicitCircuitMatch ? MATCH_SOURCE_CIRCUIT_MODE : "none"; +} + +function payloadCircuitMode(payload: Record | null): "none" | "match_source_system" { + const validation = asRecord(payload?.circuitValidation); + return validation?.mode === MATCH_SOURCE_CIRCUIT_MODE ? MATCH_SOURCE_CIRCUIT_MODE : "none"; +} function ensureRoomDesignGoal(req: ChatRequest, intent: AecTaskIntentV1, roomNumber: string): void { const objective = intent.evidence.user_text.trim() || `Lay out receptacles in Room ${roomNumber}.`; @@ -101,6 +115,11 @@ function verifiedApplyReceipt(payload: Record | null): { create const typeCounts = Array.isArray(payload.typeCounts) ? payload.typeCounts.map(asRecord) : []; if (typeCounts.some(value => value === null) || typeCounts.reduce((sum, value) => sum + (Number.isSafeInteger(value?.count) ? value!.count as number : -createdIds.length), 0) !== createdIds.length) return null; if (typeCounts.some(value => typeof value?.familyType !== "string" || !value.familyType.trim() || !Number.isSafeInteger(value.count) || (value.count as number) <= 0)) return null; + const circuitValidation = asRecord(payload.circuitValidation); + if (circuitValidation?.mode === MATCH_SOURCE_CIRCUIT_MODE) { + const assignments = Array.isArray(circuitValidation.assignments) ? circuitValidation.assignments.map(asRecord) : []; + if (circuitValidation.verified !== true || assignments.length !== createdIds.length || assignments.some(value => value?.exactMatch !== true)) return null; + } return { createdIds, readback: records, typeCounts: typeCounts as Record[] }; } @@ -121,6 +140,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte if (!planResult && !applyResult && initialRoom) { ensureRoomDesignGoal(req, intent!, initialRoom); + const circuitMode = requestedCircuitMode(intent); return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: `Preparing and validating the office-standard receptacle layout for Room ${initialRoom}…`, @@ -131,6 +151,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte body: { targetRoomNumber: initialRoom, ...(intent?.reference.kind === "room" && intent.reference.room_number ? { sourceRoomNumber: intent.reference.room_number } : {}), + ...(circuitMode === MATCH_SOURCE_CIRCUIT_MODE ? { circuitMode } : {}), includePreviewImage: true } }] @@ -146,6 +167,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte const targetRoomNumber = nestedRoomNumber(plan, "target"); const sourceRoomNumber = nestedRoomNumber(plan, "source"); const planHash = typeof plan?.planHash === "string" ? plan.planHash : ""; + const circuitMode = payloadCircuitMode(plan); const ready = plan?.ready === true && plan?.status === "ready" && !!targetRoomNumber && !!sourceRoomNumber && !!planHash; if (!ready) { appendRoomDesignProgress(req.session_id, { summary: "The analog preview did not establish a unique hash-bound project precedent.", work_items: [{ id: "precedent.resolve", title: "Resolve the strongest applicable project precedent", status: "blocked", scope: null, depends_on: ["target.inspect"], planned_actions: ["score same-level furnished analogs", "record selected precedent and assumptions"], blocker: "No complete unique analog plan was returned.", evidence_refs: [`action:${planResult.action_id}`] }, { id: "layout.preview", title: "Rollback-preview the exact mapped layout", status: "blocked", scope: null, depends_on: ["precedent.resolve"], planned_actions: ["hash-bound native preview"], blocker: "Project precedent remains unresolved.", evidence_refs: [`action:${planResult.action_id}`] }] }); @@ -174,7 +196,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte action_id: randomUUID(), method: "POST", path: APPLY_PATH, - body: { targetRoomNumber, sourceRoomNumber, planHash, includePreviewImage: true } + body: { targetRoomNumber, sourceRoomNumber, planHash, ...(circuitMode === MATCH_SOURCE_CIRCUIT_MODE ? { circuitMode } : {}), includePreviewImage: true } }] }; } @@ -209,6 +231,12 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte : previewUnavailable ? " The model write and native element readback passed, but the optional post-apply preview image was unavailable; visual confirmation remains a follow-up." : ""; + const circuitValidation = asRecord(applied?.circuitValidation); + const circuitNote = circuitValidation?.mode === MATCH_SOURCE_CIRCUIT_MODE + ? circuitValidation.engineeringReviewRequired === true + ? ` Exact source circuit-state parity passed, including ${String(circuitValidation.assignedCount ?? "the assigned")} real system memberships; ${String(circuitValidation.unassignedCount ?? "one or more")} source-matched device(s) remain intentionally unassigned and require engineering review.` + : " Every created device was verified on its exact source power-system ID. This is factual membership/load readback, not a capacity or code-compliance determination." + : ""; appendRoomDesignProgress(req.session_id, { summary: `Atomic Room ${targetRoomNumber} apply and native persistent readback passed for ${createdIds.length} receptacle(s) using Room ${sourceRoomNumber}.`, work_items: [ @@ -220,7 +248,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte updateRoomDesignPhase(req.session_id, "visual_verification", `Perform focused visual QA in Room ${targetRoomNumber}`); return { version: OPERATOR_BACKEND_CONTRACT_VERSION, - assistant_message: `Room ${targetRoomNumber} is complete. I placed and natively verified ${createdIds.length} receptacles using Room ${sourceRoomNumber} as the project-standard analog${types ? ` (${types})` : ""}. The model write was atomic and the returned host, position, orientation, type, and room readback all passed.${previewNote}`, + assistant_message: `Room ${targetRoomNumber} is complete. I placed and natively verified ${createdIds.length} receptacles using Room ${sourceRoomNumber} as the project-standard analog${types ? ` (${types})` : ""}. The model write was atomic and the returned host, position, orientation, type, and room readback all passed.${circuitNote}${previewNote}`, actions: [] }; } diff --git a/apps/operator-backend/test/room_receptacle_analog.test.ts b/apps/operator-backend/test/room_receptacle_analog.test.ts index 4551091..b10dd10 100644 --- a/apps/operator-backend/test/room_receptacle_analog.test.ts +++ b/apps/operator-backend/test/room_receptacle_analog.test.ts @@ -64,6 +64,37 @@ test("office-standard Room 403 intent enters the native preview path without mod assert.deepEqual(explicit?.actions[0]?.body, { targetRoomNumber: "403", sourceRoomNumber: "405", includePreviewImage: true }); }); +test("explicit circuit intent binds strict source-system matching through preview and apply", () => { + const intent = layoutIntent("403", "405"); + intent.evidence.user_text = "Lay out Room 403 from Room 405 and match the same circuits."; + const initial = maybeRunDeterministicRoomReceptacleAnalog(request(intent.evidence.user_text), intent); + assert.deepEqual(initial?.actions[0]?.body, { + targetRoomNumber: "403", + sourceRoomNumber: "405", + circuitMode: "match_source_system", + includePreviewImage: true + }); + + const continuation = maybeRunDeterministicRoomReceptacleAnalog(request("", [{ + action_id: "preview-circuits", + method: "POST", + path: "/revit/plan-room-receptacles-from-analog", + status: "done", + result_json: { + status: "ready", ready: true, planHash: "hash-circuit-405-403", + source: { number: "405" }, target: { number: "403" }, + circuitValidation: { mode: "match_source_system", verified: true } + } + }])); + assert.deepEqual(continuation?.actions[0]?.body, { + targetRoomNumber: "403", + sourceRoomNumber: "405", + planHash: "hash-circuit-405-403", + circuitMode: "match_source_system", + includePreviewImage: true + }); +}); + test("verified rollback preview advances to the exact hash-bound analog apply", () => { const response = maybeRunDeterministicRoomReceptacleAnalog(request("", [{ action_id: "preview", @@ -177,6 +208,12 @@ test("completion receipt requires exact current-run ids plus type, room, host, p mutate(copy => { delete copy.readback[0].semanticAnchor; }), mutate(copy => { copy.typeCounts[0].count = 2; }) ]) assert.equal(__testOnlyVerifiedAnalogApplyReceipt(invalid), null); + + const circuitValid = appliedReceipt([1700001], [{ familyType: "Duplex Receptacle|Standard", count: 1 }]) as any; + circuitValid.circuitValidation = { mode: "match_source_system", verified: true, assignments: [{ exactMatch: true }] }; + assert.ok(__testOnlyVerifiedAnalogApplyReceipt(circuitValid)); + circuitValid.circuitValidation.assignments[0].exactMatch = false; + assert.equal(__testOnlyVerifiedAnalogApplyReceipt(circuitValid), null); }); test("applied receipt keeps success truthful while surfacing post-commit preview warnings", () => { diff --git a/apps/revit-bridge-addin/RevitBridge.Common.Tests/CircuitMatchPolicyTests.cs b/apps/revit-bridge-addin/RevitBridge.Common.Tests/CircuitMatchPolicyTests.cs new file mode 100644 index 0000000..2248d97 --- /dev/null +++ b/apps/revit-bridge-addin/RevitBridge.Common.Tests/CircuitMatchPolicyTests.cs @@ -0,0 +1,53 @@ +using System; +using RevitBridge.Common.Electrical; +using Xunit; + +namespace RevitBridge.Common.Tests +{ + public sealed class CircuitMatchPolicyTests + { + [Theory] + [InlineData(null, "none")] + [InlineData("", "none")] + [InlineData("NONE", "none")] + [InlineData(" match_source_system ", "match_source_system")] + public void NormalizesSupportedModes(string? value, string expected) + { + Assert.Equal(expected, CircuitMatchPolicy.NormalizeMode(value)); + } + + [Fact] + public void RejectsUnsupportedMode() + { + Assert.Throws(() => CircuitMatchPolicy.NormalizeMode("copy_labels")); + } + + [Theory] + [InlineData("PowerCircuit", true)] + [InlineData("PowerBalanced", true)] + [InlineData("PowerUnBalanced", true)] + [InlineData("Data", false)] + [InlineData(null, false)] + public void RecognizesOnlyPowerSystems(string? value, bool expected) + { + Assert.Equal(expected, CircuitMatchPolicy.IsPowerSystemType(value)); + } + + [Fact] + public void ExactMembershipRequiresOneMatchingSystem() + { + Assert.True(CircuitMatchPolicy.HasExactMembership(42, new long[] { 42 })); + Assert.False(CircuitMatchPolicy.HasExactMembership(42, Array.Empty())); + Assert.False(CircuitMatchPolicy.HasExactMembership(42, new long[] { 41 })); + Assert.False(CircuitMatchPolicy.HasExactMembership(42, new long[] { 42, 43 })); + } + + [Fact] + public void FactualDeltaRejectsMissingOrNonFiniteValues() + { + Assert.Equal(12.5, CircuitMatchPolicy.FactualDelta(100, 112.5)); + Assert.Null(CircuitMatchPolicy.FactualDelta(null, 112.5)); + Assert.Null(CircuitMatchPolicy.FactualDelta(100, double.NaN)); + } + } +} diff --git a/apps/revit-bridge-addin/RevitBridge.Common/Electrical/CircuitMatchPolicy.cs b/apps/revit-bridge-addin/RevitBridge.Common/Electrical/CircuitMatchPolicy.cs new file mode 100644 index 0000000..cb14904 --- /dev/null +++ b/apps/revit-bridge-addin/RevitBridge.Common/Electrical/CircuitMatchPolicy.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RevitBridge.Common.Electrical +{ + public static class CircuitMatchPolicy + { + public const string None = "none"; + public const string MatchSourceSystem = "match_source_system"; + + public static string NormalizeMode(string? value) + { + var mode = (value ?? string.Empty).Trim().ToLowerInvariant(); + if (mode.Length == 0 || mode == None) return None; + if (mode == MatchSourceSystem) return MatchSourceSystem; + throw new ArgumentException("Unsupported circuitMode. Expected 'none' or 'match_source_system'."); + } + + public static bool IsPowerSystemType(string? value) + { + var type = (value ?? string.Empty).Trim(); + return type.Equals("PowerCircuit", StringComparison.OrdinalIgnoreCase) || + type.Equals("PowerBalanced", StringComparison.OrdinalIgnoreCase) || + type.Equals("PowerUnBalanced", StringComparison.OrdinalIgnoreCase); + } + + public static bool HasExactMembership(long expectedSystemId, IEnumerable? actualPowerSystemIds) + { + var ids = (actualPowerSystemIds ?? Enumerable.Empty()).Distinct().ToList(); + return expectedSystemId > 0 && ids.Count == 1 && ids[0] == expectedSystemId; + } + + public static double? FactualDelta(double? before, double? after) + { + if (!before.HasValue || !after.HasValue || + double.IsNaN(before.Value) || double.IsInfinity(before.Value) || + double.IsNaN(after.Value) || double.IsInfinity(after.Value)) return null; + return after.Value - before.Value; + } + } +} diff --git a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/HostedPlacementHandlers.cs b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/HostedPlacementHandlers.cs index 887e451..bc26c38 100644 --- a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/HostedPlacementHandlers.cs +++ b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/HostedPlacementHandlers.cs @@ -13,6 +13,7 @@ using Autodesk.Revit.DB.Structure; using Autodesk.Revit.UI; using RevitBridge.Common; +using RevitBridge.Common.Electrical; using RevitBridge.Common.Spatial; using RevitBridge.Logic.Handlers.Core; @@ -457,7 +458,7 @@ internal static string GetSpatialKind(SpatialElement? spatial) }; } - internal static ResolvedSpatialContext? FindSpatialElement(Document doc, long? spatialId, string? roomNumber) + internal static ResolvedSpatialContext? FindSpatialElement(Document doc, long? spatialId, string? roomNumber, string? spatialKindPreference = "auto") { if (spatialId.HasValue && spatialId.Value > 0) { @@ -477,7 +478,7 @@ internal static string GetSpatialKind(SpatialElement? spatial) } } - var resolved = SpatialElementResolver.ResolveByNumber(doc, roomNumber ?? "", "auto"); + var resolved = SpatialElementResolver.ResolveByNumber(doc, roomNumber ?? "", spatialKindPreference); if (resolved?.Element == null) return null; return new ResolvedSpatialContext { @@ -1811,6 +1812,7 @@ internal static bool TryReassignElectricalCircuitFromSource( } var sourceLabel = BuildElectricalCircuitLabel(sourceFi, sourceCircuit); + var sourceSystemId = TryGetElectricalSystemId(sourceCircuit); var sourceNormalized = NormalizeElectricalCircuitLabel(sourceLabel); var targetSystemsBefore = GetElectricalSystems(target); var removedLabels = new List(); @@ -1856,6 +1858,12 @@ internal static bool TryReassignElectricalCircuitFromSource( var finalNormalized = NormalizeElectricalCircuitLabel(finalLabel); if (requireMatch) { + var finalPowerSystemIds = GetPowerElectricalSystemIds(target); + if (!sourceSystemId.HasValue || !CircuitMatchPolicy.HasExactMembership(sourceSystemId.Value, finalPowerSystemIds)) + { + detail = $"Adjusted instance did not join exactly the source power system. Expected system {sourceSystemId?.ToString(CultureInfo.InvariantCulture) ?? "unknown"}; actual power systems: {string.Join(",", finalPowerSystemIds)}."; + return false; + } if (sourceNormalized.Length > 0 && finalNormalized.Length > 0 && !string.Equals(sourceNormalized, finalNormalized, StringComparison.OrdinalIgnoreCase)) { detail = $"Adjusted instance joined an unexpected electrical circuit. Expected {sourceLabel}, got {finalLabel}."; @@ -1883,6 +1891,8 @@ internal static object BuildElectricalCircuitAuditPayload(FamilyInstance? instan var labels = new List(); var normalized = new List(); var systems = instance == null ? new List() : GetElectricalSystems(instance); + var systemIds = systems.Select(TryGetElectricalSystemId).Where(id => id.HasValue).Select(id => id!.Value).Distinct().OrderBy(id => id).ToList(); + var powerSystemIds = systems.Where(LooksLikePowerCircuit).Select(TryGetElectricalSystemId).Where(id => id.HasValue).Select(id => id!.Value).Distinct().OrderBy(id => id).ToList(); if (instance != null) { foreach (var system in systems) @@ -1907,10 +1917,26 @@ internal static object BuildElectricalCircuitAuditPayload(FamilyInstance? instan primaryLabel = primaryLabel.Length > 0 ? primaryLabel : null, labels, normalizedLabels = normalized, - systemCount = systems.Count + systemCount = systems.Count, + systemIds, + powerSystemIds, + exactPowerSystemCount = powerSystemIds.Count }; } + internal static List GetPowerElectricalSystemIds(FamilyInstance? instance) + { + if (instance == null) return new List(); + return GetElectricalSystems(instance) + .Where(LooksLikePowerCircuit) + .Select(TryGetElectricalSystemId) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .Distinct() + .OrderBy(id => id) + .ToList(); + } + private static object? ResolvePreferredElectricalSystem(FamilyInstance sourceFi) { var systems = GetElectricalSystems(sourceFi); @@ -1994,6 +2020,18 @@ private static bool LooksLikePowerCircuit(object system) return false; } + private static long? TryGetElectricalSystemId(object? system) + { + if (system is Element element) return ElementIdCompat.GetValue(element.Id); + try + { + var value = system?.GetType().GetProperty("Id", BindingFlags.Instance | BindingFlags.Public)?.GetValue(system); + if (value is ElementId id) return ElementIdCompat.GetValue(id); + } + catch { } + return null; + } + private static bool TryAddFamilyInstanceToElectricalSystem(object system, FamilyInstance created, out string error) { error = ""; diff --git a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/RankSimilarDevicesOnWallHandler.cs b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/RankSimilarDevicesOnWallHandler.cs index c7a9968..3c5019d 100644 --- a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/RankSimilarDevicesOnWallHandler.cs +++ b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/RankSimilarDevicesOnWallHandler.cs @@ -10,6 +10,7 @@ using Autodesk.Revit.DB.Mechanical; using Autodesk.Revit.UI; using RevitBridge.Common; +using RevitBridge.Common.Electrical; using RevitBridge.Logic.Handlers.Core; namespace RevitBridge.Logic.Handlers @@ -491,7 +492,7 @@ public sealed class Params public long? sourceElementId { get; set; } public bool dryRun { get; set; } = true; public bool confirm { get; set; } = false; - public bool parameterOnlyFallback { get; set; } = true; + public bool parameterOnlyFallback { get; set; } = false; } public Task Handle(UIApplication app, string jsonData) @@ -535,21 +536,28 @@ public Task Handle(UIApplication app, string jsonData) var detail = ""; if (!apply && source != null && fi != null) { + var sourcePowerSystemIds = HostedPlacementUtil.GetPowerElectricalSystemIds(source as FamilyInstance); var sourceAudit = HostedPlacementUtil.BuildElectricalCircuitAuditPayload(source as FamilyInstance); var sourceLabel = ReadAnonymousString(sourceAudit, "primaryLabel"); action = "preflight_match_source_electrical_system"; - ok = !string.IsNullOrWhiteSpace(sourceLabel); + ok = sourcePowerSystemIds.Count == 1; detail = ok - ? $"Source exemplar has a resolvable electrical circuit ({sourceLabel}). Apply with dryRun:false and confirm:true to join it." - : "Source exemplar has no resolvable electrical circuit."; + ? $"Source exemplar has exactly one power system ({sourcePowerSystemIds[0]}; {sourceLabel}). Apply with dryRun:false and confirm:true to join it." + : $"Source exemplar must have exactly one power system; found {sourcePowerSystemIds.Count}."; } else if (apply && source != null && fi != null) { - ok = HostedPlacementUtil.TryReassignElectricalCircuitFromSource(source, fi, false, warnings, out detail); + var sourcePowerSystemIds = HostedPlacementUtil.GetPowerElectricalSystemIds(source as FamilyInstance); + if (sourcePowerSystemIds.Count != 1) + throw new InvalidOperationException($"source_element_requires_one_power_system:{p.sourceElementId}:found={sourcePowerSystemIds.Count}"); + ok = HostedPlacementUtil.TryReassignElectricalCircuitFromSource(source, fi, true, warnings, out detail); action = "match_source_electrical_system"; + var finalPowerSystemIds = HostedPlacementUtil.GetPowerElectricalSystemIds(fi); + if (!ok || !CircuitMatchPolicy.HasExactMembership(sourcePowerSystemIds[0], finalPowerSystemIds)) + throw new InvalidOperationException("exact_source_power_system_assignment_failed:" + id + ":" + detail); } - if ((!apply || !ok) && p.parameterOnlyFallback) + if (source == null && (!apply || !ok) && p.parameterOnlyFallback) { var setPanel = TrySetStringParameter(element, "Panel", p.panelName, apply, out var panelDetail); var setCircuit = TrySetStringParameter(element, "Circuit Number", p.circuitNumber, apply, out var circuitDetail); @@ -583,10 +591,10 @@ public Task Handle(UIApplication app, string jsonData) return Task.FromResult(new { - schema = "operator.assign_electrical_circuit.v1", + schema = "operator.assign_electrical_circuit.v2", applied = apply, - mode = source != null ? "source_system_first_then_parameter_fallback" : "parameter_fallback", - limitation = "When sourceElementId is supplied, the handler attempts real ElectricalSystem reassignment. Direct panel/circuit values fall back to writable instance parameters only; Revit may keep system-owned circuit fields read-only.", + mode = source != null ? "strict_exact_source_power_system" : p.parameterOnlyFallback ? "explicit_parameter_only_fallback" : "no_assignment_mode_selected", + limitation = "sourceElementId uses atomic real ElectricalSystem reassignment with exact source-system-ID readback. Direct panel/circuit values are labels only and are attempted solely when parameterOnlyFallback:true is explicitly requested; they do not prove circuit membership or electrical compliance.", results, warnings }); diff --git a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/RoomReceptacleAnalogHandlers.cs b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/RoomReceptacleAnalogHandlers.cs index e291056..5a10adb 100644 --- a/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/RoomReceptacleAnalogHandlers.cs +++ b/apps/revit-bridge-addin/RevitBridge.Logic/Handlers/RoomReceptacleAnalogHandlers.cs @@ -7,9 +7,11 @@ using System.Threading.Tasks; using Autodesk.Revit.DB; using Autodesk.Revit.DB.Architecture; +using Autodesk.Revit.DB.Electrical; using Autodesk.Revit.DB.Mechanical; using Autodesk.Revit.UI; using RevitBridge.Common; +using RevitBridge.Common.Electrical; using RevitBridge.Common.LowVoltage.Core.Geometry; using RevitBridge.Common.LowVoltage.Skills.DwellingReceptacles; @@ -21,6 +23,7 @@ public sealed class RoomReceptacleAnalogParams public string? sourceRoomNumber { get; set; } public long? viewId { get; set; } public bool includePreviewImage { get; set; } = true; + public string circuitMode { get; set; } = CircuitMatchPolicy.None; public string? planHash { get; set; } } @@ -62,6 +65,8 @@ private sealed class DeviceRecord public string SourceHostStableReference { get; set; } = string.Empty; public string SourceCircuitPanel { get; set; } = string.Empty; public string SourceCircuitNumber { get; set; } = string.Empty; + public ElectricalSystem? SourcePowerSystem { get; set; } + public CircuitSnapshot? SourceCircuitSnapshot { get; set; } public XYZ PreferredDirection { get; set; } = XYZ.BasisX; public bool SemanticHostIsExactPhysical { get; set; } } @@ -101,12 +106,32 @@ private sealed class PreparedContext public List Placements { get; set; } = new List(); public DwellingReceptacleAnalogCandidateSelection Selection { get; set; } = new DwellingReceptacleAnalogCandidateSelection(); public string PlanHash { get; set; } = string.Empty; + public string CircuitMode { get; set; } = CircuitMatchPolicy.None; + } + + private sealed class CircuitSnapshot + { + public long SystemId { get; set; } + public string SystemType { get; set; } = string.Empty; + public long? PanelElementId { get; set; } + public string PanelName { get; set; } = string.Empty; + public string CircuitNumber { get; set; } = string.Empty; + public double? VoltageInternal { get; set; } + public string VoltageDisplay { get; set; } = string.Empty; + public int? Poles { get; set; } + public string LoadClassifications { get; set; } = string.Empty; + public double? TrueLoadInternal { get; set; } + public string TrueLoadDisplay { get; set; } = string.Empty; + public double? ApparentLoadInternal { get; set; } + public string ApparentLoadDisplay { get; set; } = string.Empty; } private sealed class CreationReceipt { public List CreatedIds { get; } = new List(); public List Readback { get; } = new List(); + public List CircuitSystems { get; } = new List(); + public List CircuitAssignments { get; } = new List(); public List Failures { get; } = new List(); } @@ -118,6 +143,7 @@ internal static object Execute(UIApplication app, string jsonData, bool apply) : JsonSerializer.Deserialize(jsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RoomReceptacleAnalogParams(); request.targetRoomNumber = (request.targetRoomNumber ?? string.Empty).Trim(); request.sourceRoomNumber = string.IsNullOrWhiteSpace(request.sourceRoomNumber) ? null : request.sourceRoomNumber.Trim(); + request.circuitMode = CircuitMatchPolicy.NormalizeMode(request.circuitMode); if (request.targetRoomNumber.Length == 0) throw new ArgumentException("targetRoomNumber is required."); if (apply && string.IsNullOrWhiteSpace(request.planHash)) throw new ArgumentException("planHash is required for apply."); @@ -228,7 +254,7 @@ private static (string? path, int? widthPx, int? heightPx) TryExportPostApplyPre private static PreparedContext Prepare(Document document, View activeView, RoomReceptacleAnalogParams request) { - var targetSpatial = HostedPlacementUtil.FindSpatialElement(document, null, request.targetRoomNumber) + var targetSpatial = HostedPlacementUtil.FindSpatialElement(document, null, request.targetRoomNumber, "space") ?? throw new InvalidOperationException($"Target Room/Space '{request.targetRoomNumber}' was not found."); var planView = ResolvePowerPlanView(document, targetSpatial.element, request.viewId) ?? throw new InvalidOperationException("No same-level floor/power plan view was found for the target."); @@ -241,7 +267,7 @@ private static PreparedContext Prepare(Document document, View activeView, RoomR DwellingReceptacleAnalogCandidateSelection selection; if (!string.IsNullOrWhiteSpace(request.sourceRoomNumber)) { - var explicitSpatial = HostedPlacementUtil.FindSpatialElement(document, null, request.sourceRoomNumber) + var explicitSpatial = HostedPlacementUtil.FindSpatialElement(document, null, request.sourceRoomNumber, "space") ?? throw new InvalidOperationException($"Source Room/Space '{request.sourceRoomNumber}' was not found."); source = BuildSpatialRecord(document, explicitSpatial); if (source.Receptacles.Count == 0) throw new InvalidOperationException("source_room_has_no_receptacles"); @@ -282,7 +308,7 @@ private static PreparedContext Prepare(Document document, View activeView, RoomR source = eligibleCandidates.Single(candidate => string.Equals(candidate.Spatial.id.ToString(CultureInfo.InvariantCulture), selection.SelectedRoomScopedId, StringComparison.Ordinal)); } - var devices = BuildDeviceRecords(document, source); + var devices = BuildDeviceRecords(document, source, request.circuitMode); var usedSourceAnchorIds = new HashSet(devices.Select(device => device.PlannerDevice.SourceHostScopedId).Where(id => !string.IsNullOrWhiteSpace(id))!, StringComparer.Ordinal); var sourcePlannerAnchors = source.Anchors.Where(anchor => usedSourceAnchorIds.Contains(anchor.Value.ScopedId)).ToList(); if (sourcePlannerAnchors.Count != usedSourceAnchorIds.Count) @@ -313,7 +339,8 @@ private static PreparedContext Prepare(Document document, View activeView, RoomR Target = target, Devices = devices, AnalogPlan = analogPlan, - Selection = selection + Selection = selection, + CircuitMode = request.circuitMode }; context.Placements = PreparePhysicalPlacements(context); ValidatePreparedPlacements(context); @@ -785,6 +812,7 @@ private static CreationReceipt CreateAndVerify(PreparedContext context, string t receipt.CreatedIds.Add(ElementIdCompat.GetValue(created.Id)); } context.Document.Regenerate(); + AssignRequestedCircuits(context, receipt); for (var index = 0; index < receipt.CreatedIds.Count; index++) { var placement = context.Placements[index]; @@ -800,7 +828,31 @@ private static CreationReceipt CreateAndVerify(PreparedContext context, string t if (!IsFinite(orientationAgreement) || orientationAgreement < 0.98) throw new InvalidOperationException("created_orientation_mismatch"); var facingAgreement = Math.Abs(SafeDirection(created.GetTransform().BasisZ).DotProduct(placement.Face.FaceNormal)); if (!IsFinite(facingAgreement) || facingAgreement < 0.95) throw new InvalidOperationException("created_facing_mismatch"); - if (HasCircuit(created)) throw new InvalidOperationException("created_unexpected_circuit_membership"); + var actualPowerSystems = GetPowerSystems(created); + var expectedPowerSystem = placement.Source.SourcePowerSystem; + if (context.CircuitMode == CircuitMatchPolicy.MatchSourceSystem) + { + var actualIds = actualPowerSystems.Select(system => ElementIdCompat.GetValue(system.Id)).ToList(); + var expectedId = expectedPowerSystem == null ? (long?)null : ElementIdCompat.GetValue(expectedPowerSystem.Id); + var exactMatch = expectedId.HasValue + ? CircuitMatchPolicy.HasExactMembership(expectedId.Value, actualIds) + : actualIds.Count == 0 && !HasCircuit(created); + if (!exactMatch) throw new InvalidOperationException("created_power_system_membership_mismatch:" + receipt.CreatedIds[index]); + receipt.CircuitAssignments.Add(new + { + sourceElementId = ElementIdCompat.GetValue(placement.Source.Instance.Id), + createdElementId = receipt.CreatedIds[index], + expectedSystemId = expectedId, + actualPowerSystemIds = actualIds, + exactMatch = true, + status = expectedId.HasValue ? "matched_exact_source_power_system" : "source_unassigned_state_preserved", + engineeringReviewRequired = !expectedId.HasValue + }); + } + else if (actualPowerSystems.Count != 0 || HasCircuit(created)) + { + throw new InvalidOperationException("created_unexpected_circuit_membership"); + } receipt.Readback.Add(new { id = receipt.CreatedIds[index], @@ -824,6 +876,41 @@ private static CreationReceipt CreateAndVerify(PreparedContext context, string t } } + private static void AssignRequestedCircuits(PreparedContext context, CreationReceipt receipt) + { + if (context.CircuitMode != CircuitMatchPolicy.MatchSourceSystem) return; + var indexed = context.Placements.Select((placement, index) => new { placement, index }).ToList(); + foreach (var group in indexed.Where(item => item.placement.Source.SourcePowerSystem != null).GroupBy(item => ElementIdCompat.GetValue(item.placement.Source.SourcePowerSystem!.Id)).OrderBy(group => group.Key)) + { + if (group.Key <= 0) throw new InvalidOperationException("source_power_system_invalid_for_assignment"); + var system = group.First().placement.Source.SourcePowerSystem ?? throw new InvalidOperationException("source_power_system_missing_for_assignment"); + var before = CaptureCircuitSnapshot(system); + var components = new ElementSet(); + foreach (var item in group) + { + var created = context.Document.GetElement(ElementIdCompat.Create(receipt.CreatedIds[item.index])); + if (created == null || !components.Insert(created)) throw new InvalidOperationException("created_circuit_component_set_failed"); + } + if (!system.AddToCircuit(components)) throw new InvalidOperationException("source_power_system_add_failed:" + group.Key); + context.Document.Regenerate(); + var after = CaptureCircuitSnapshot(system); + receipt.CircuitSystems.Add(new + { + systemId = group.Key, + addedElementIds = group.Select(item => receipt.CreatedIds[item.index]).ToList(), + before, + after, + factualLoadDelta = new + { + unitBasis = "Revit internal electrical units", + trueLoad = CircuitMatchPolicy.FactualDelta(before.TrueLoadInternal, after.TrueLoadInternal), + apparentLoad = CircuitMatchPolicy.FactualDelta(before.ApparentLoadInternal, after.ApparentLoadInternal) + }, + complianceDetermination = (string?)null + }); + } + } + private static void VerifyPersistentInventory(PreparedContext context, IReadOnlyCollection createdIds) { var actual = FindReceptacles(context.Document, context.Target.Spatial.element); @@ -833,6 +920,25 @@ private static void VerifyPersistentInventory(PreparedContext context, IReadOnly var actualTypes = actual.GroupBy(FamilyTypeKey, StringComparer.Ordinal).ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); if (expectedTypes.Count != actualTypes.Count || expectedTypes.Any(pair => !actualTypes.TryGetValue(pair.Key, out var count) || count != pair.Value)) throw new InvalidOperationException("post_commit_type_inventory_mismatch"); + for (var index = 0; index < context.Placements.Count; index++) + { + var created = context.Document.GetElement(ElementIdCompat.Create(createdIds.ElementAt(index))) as FamilyInstance + ?? throw new InvalidOperationException("post_commit_created_instance_missing"); + var powerSystemIds = GetPowerSystems(created).Select(system => ElementIdCompat.GetValue(system.Id)).ToList(); + if (context.CircuitMode == CircuitMatchPolicy.MatchSourceSystem) + { + var expectedSystem = context.Placements[index].Source.SourcePowerSystem; + var exactMatch = expectedSystem != null + ? CircuitMatchPolicy.HasExactMembership(ElementIdCompat.GetValue(expectedSystem.Id), powerSystemIds) + : powerSystemIds.Count == 0 && !HasCircuit(created); + if (!exactMatch) + throw new InvalidOperationException("post_commit_power_system_membership_mismatch:" + ElementIdCompat.GetValue(created.Id)); + } + else if (powerSystemIds.Count != 0 || HasCircuit(created)) + { + throw new InvalidOperationException("post_commit_unexpected_circuit_membership:" + ElementIdCompat.GetValue(created.Id)); + } + } } private static (string? path, int? widthPx, int? heightPx) ExportPreview(PreparedContext context, IList ids, List warnings) @@ -874,8 +980,29 @@ private static object BuildResponse(PreparedContext context, CreationReceipt rec mappingBasis = placement.Planned.MappingBasis, semanticAnchor = new { source = placement.Planned.SourceAnchorScopedId, target = placement.Planned.TargetHostAnchorScopedId, signature = placement.Planned.TargetAnchorSignature }, physicalHost = new { sourceLinkedElementId = ElementIdCompat.GetValue(placement.Source.PhysicalLinkedElementId), targetLinkedElementId = ElementIdCompat.GetValue(placement.LinkedElementId), linkInstanceId = ElementIdCompat.GetValue(placement.Link.Id), placement.PhysicalMappingBasis, faceFingerprint = placement.Face.FaceFingerprint }, - sourceCircuit = new { panel = EmptyToNull(placement.Source.SourceCircuitPanel), circuitNumber = EmptyToNull(placement.Source.SourceCircuitNumber), policy = "record_source_do_not_copy" } + sourceCircuit = new + { + panel = EmptyToNull(placement.Source.SourceCircuitPanel), + circuitNumber = EmptyToNull(placement.Source.SourceCircuitNumber), + systemId = placement.Source.SourceCircuitSnapshot?.SystemId, + system = placement.Source.SourceCircuitSnapshot, + policy = context.CircuitMode == CircuitMatchPolicy.MatchSourceSystem ? "match_exact_source_power_system" : "record_source_do_not_copy" + } }).ToList(), + circuitValidation = new + { + mode = context.CircuitMode, + attempted = context.CircuitMode == CircuitMatchPolicy.MatchSourceSystem, + verified = context.CircuitMode == CircuitMatchPolicy.MatchSourceSystem + ? receipt.CircuitAssignments.Count == context.Placements.Count + : receipt.CircuitAssignments.Count == 0, + assignments = receipt.CircuitAssignments, + systems = receipt.CircuitSystems, + assignedCount = context.Placements.Count(placement => placement.Source.SourcePowerSystem != null), + unassignedCount = context.Placements.Count(placement => placement.Source.SourcePowerSystem == null), + engineeringReviewRequired = context.Placements.Any(placement => placement.Source.SourcePowerSystem == null), + scope = "Factual Revit system membership and before/after panel-circuit-load readback only; no capacity, breaker, conductor, demand, code, or AHJ compliance determination." + }, readback = applied ? receipt.Readback : new List(), previewVerification = applied ? null : new { temporaryCount = receipt.Readback.Count, verified = receipt.Readback.Count == context.Placements.Count, idsPersisted = false }, createdIds = applied ? receipt.CreatedIds : new List(), @@ -979,7 +1106,7 @@ private static IEnumerable EnumerateCandidateSpatials(Document do private static DwellingReceptacleAnalogCandidate ToCandidate(Document document, SpatialRecord record, SpatialRecord target, bool usedSemanticOnly) { var signatures = usedSemanticOnly - ? BuildDeviceRecords(document, record).Select(device => device.PlannerDevice.SourceHostSignature).Where(signature => !string.IsNullOrWhiteSpace(signature)).Cast().OrderBy(signature => signature, StringComparer.Ordinal).ToList() + ? BuildDeviceRecords(document, record, CircuitMatchPolicy.None).Select(device => device.PlannerDevice.SourceHostSignature).Where(signature => !string.IsNullOrWhiteSpace(signature)).Cast().OrderBy(signature => signature, StringComparer.Ordinal).ToList() : record.Anchors.Select(anchor => DwellingReceptacleAnalogPlanner.NormalizedSignature(anchor.Value)).ToList(); return new DwellingReceptacleAnalogCandidate { @@ -1080,7 +1207,7 @@ private static List FindCuratedAnchors(Document document, SpatialE return result.OrderBy(anchor => anchor.Value.ScopedId, StringComparer.Ordinal).ToList(); } - private static List BuildDeviceRecords(Document document, SpatialRecord source) + private static List BuildDeviceRecords(Document document, SpatialRecord source, string circuitMode) { var byPhysicalHost = source.Anchors.Where(anchor => anchor.Link != null).ToDictionary(anchor => AnchorKey(anchor.Link!.Id, anchor.LinkedElementId), StringComparer.Ordinal); var semanticCandidates = source.Anchors.Where(anchor => !IsWallCategory(anchor.Value.Category, anchor.Value.Category)).ToList(); @@ -1113,6 +1240,12 @@ private static List BuildDeviceRecords(Document document, SpatialR planner.SourceHostSignature = DwellingReceptacleAnalogPlanner.NormalizedSignature(semantic.Value); planner.SourceHostCategory = semantic.Value.Category; } + var sourcePowerSystem = circuitMode == CircuitMatchPolicy.MatchSourceSystem + ? ResolveSourcePowerSystemState(instance) + : null; + var sourceCircuitSnapshot = sourcePowerSystem == null ? null : CaptureCircuitSnapshot(sourcePowerSystem); + if (sourceCircuitSnapshot != null && (!sourceCircuitSnapshot.PanelElementId.HasValue || string.IsNullOrWhiteSpace(sourceCircuitSnapshot.PanelName) || string.IsNullOrWhiteSpace(sourceCircuitSnapshot.CircuitNumber))) + throw new InvalidOperationException("source_power_system_not_panel_assigned:" + ElementIdCompat.GetValue(instance.Id)); result.Add(new DeviceRecord { Instance = instance, @@ -1126,6 +1259,8 @@ private static List BuildDeviceRecords(Document document, SpatialR SourceHostStableReference = SafeStableReference(document, hostFace), SourceCircuitPanel = ReadParameter(instance, "Panel"), SourceCircuitNumber = ReadParameter(instance, "Circuit Number"), + SourcePowerSystem = sourcePowerSystem, + SourceCircuitSnapshot = sourceCircuitSnapshot, PreferredDirection = SafeDirection(instance.HandOrientation), SemanticHostIsExactPhysical = semanticHostIsExactPhysical }); @@ -1165,7 +1300,8 @@ private static string ComputePlanHash(PreparedContext context) Pair("document.project", context.Document.ProjectInformation?.UniqueId ?? string.Empty), Pair("source.room", context.Source.Spatial.id.ToString(CultureInfo.InvariantCulture)), Pair("target.room", context.Target.Spatial.id.ToString(CultureInfo.InvariantCulture)), - Pair("view", ElementIdCompat.GetValue(context.PlanView.Id).ToString(CultureInfo.InvariantCulture)) + Pair("view", ElementIdCompat.GetValue(context.PlanView.Id).ToString(CultureInfo.InvariantCulture)), + Pair("circuit.mode", context.CircuitMode) }; foreach (var anchor in context.Source.Anchors.OrderBy(anchor => anchor.Value.ScopedId, StringComparer.Ordinal)) fields.Add(Pair("source.anchor." + anchor.Value.ScopedId, AnchorFingerprint(anchor))); foreach (var anchor in context.Target.Anchors.OrderBy(anchor => anchor.Value.ScopedId, StringComparer.Ordinal)) fields.Add(Pair("target.anchor." + anchor.Value.ScopedId, AnchorFingerprint(anchor))); @@ -1177,6 +1313,7 @@ private static string ComputePlanHash(PreparedContext context) fields.Add(Pair(prefix + ".semantic", (placement.Planned.TargetHostAnchorScopedId ?? string.Empty) + "|" + (placement.Planned.TargetAnchorSignature ?? string.Empty))); fields.Add(Pair(prefix + ".physical", placement.Face.LinkedElementUniqueId + "|" + placement.Face.FaceFingerprint)); fields.Add(Pair(prefix + ".source_circuit", placement.Source.SourceCircuitPanel + "|" + placement.Source.SourceCircuitNumber)); + fields.Add(Pair(prefix + ".source_system", CircuitFingerprint(placement.Source.SourceCircuitSnapshot))); } return DwellingReceptacleAnalogRuntime.ComputePlanHash(fields); } @@ -1274,6 +1411,68 @@ private static bool IsSemanticDeviceType(FamilyInstance instance) private static bool IsWallCategory(string builtIn, string category) => builtIn.Equals("OST_Walls", StringComparison.OrdinalIgnoreCase) || category.Equals("Walls", StringComparison.OrdinalIgnoreCase) || builtIn.Equals("OST_Cornices", StringComparison.OrdinalIgnoreCase) || category.Equals("Wall Sweeps", StringComparison.OrdinalIgnoreCase); private static bool HasCircuit(FamilyInstance instance) => ReadParameter(instance, "Panel").Length > 0 || ReadParameter(instance, "Circuit Number").Length > 0; + private static List GetPowerSystems(FamilyInstance instance) + { + try + { + return (instance.MEPModel?.GetElectricalSystems() ?? new HashSet()) + .Where(system => system != null && CircuitMatchPolicy.IsPowerSystemType(system.SystemType.ToString())) + .OrderBy(system => ElementIdCompat.GetValue(system.Id)) + .ToList(); + } + catch { return new List(); } + } + + private static ElectricalSystem? ResolveSourcePowerSystemState(FamilyInstance instance) + { + var systems = GetPowerSystems(instance); + if (systems.Count > 1) + throw new InvalidOperationException("source_device_has_ambiguous_power_systems:" + ElementIdCompat.GetValue(instance.Id) + ":found=" + systems.Count); + return systems.SingleOrDefault(); + } + + private static CircuitSnapshot CaptureCircuitSnapshot(ElectricalSystem system) + { + Element? panel = null; + try { panel = system.BaseEquipment; } catch { } + return new CircuitSnapshot + { + SystemId = ElementIdCompat.GetValue(system.Id), + SystemType = SafeCircuitString(() => system.SystemType.ToString()), + PanelElementId = panel == null ? (long?)null : ElementIdCompat.GetValue(panel.Id), + PanelName = SafeCircuitString(() => system.PanelName), + CircuitNumber = SafeCircuitString(() => system.CircuitNumber), + VoltageInternal = SafeCircuitDouble(() => system.Voltage), + VoltageDisplay = ReadDisplayParameter(system, "Voltage"), + Poles = SafeCircuitInt(() => system.PolesNumber), + LoadClassifications = SafeCircuitString(() => system.LoadClassifications), + TrueLoadInternal = SafeCircuitDouble(() => system.TrueLoad), + TrueLoadDisplay = ReadDisplayParameter(system, "True Load"), + ApparentLoadInternal = SafeCircuitDouble(() => system.ApparentLoad), + ApparentLoadDisplay = ReadDisplayParameter(system, "Apparent Load") + }; + } + + private static string CircuitFingerprint(CircuitSnapshot? snapshot) + { + if (snapshot == null) return string.Empty; + return string.Join("|", new[] + { + snapshot.SystemId.ToString(CultureInfo.InvariantCulture), snapshot.SystemType, + snapshot.PanelElementId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + snapshot.PanelName, snapshot.CircuitNumber, + snapshot.VoltageInternal?.ToString("R", CultureInfo.InvariantCulture) ?? string.Empty, + snapshot.Poles?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + snapshot.LoadClassifications, + snapshot.TrueLoadInternal?.ToString("R", CultureInfo.InvariantCulture) ?? string.Empty, + snapshot.ApparentLoadInternal?.ToString("R", CultureInfo.InvariantCulture) ?? string.Empty + }); + } + + private static string SafeCircuitString(Func getter) { try { return (getter() ?? string.Empty).Trim(); } catch { return string.Empty; } } + private static double? SafeCircuitDouble(Func getter) { try { var value = getter(); return IsFinite(value) ? value : (double?)null; } catch { return null; } } + private static int? SafeCircuitInt(Func getter) { try { return getter(); } catch { return null; } } + private static string ReadDisplayParameter(Element element, string name) { try { return (element.LookupParameter(name)?.AsValueString() ?? string.Empty).Trim(); } catch { return string.Empty; } } private static string SafeStableReference(Document document, Reference reference) { try { return reference.ConvertToStableRepresentation(document) ?? string.Empty; } catch { return string.Empty; } } private static string ReadParameter(Element element, string name) => (element.LookupParameter(name)?.AsString() ?? element.LookupParameter(name)?.AsValueString() ?? string.Empty).Trim(); private static string? EmptyToNull(string value) => string.IsNullOrWhiteSpace(value) ? null : value; diff --git a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs index e318a7e..50740c5 100644 --- a/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs +++ b/apps/revit-bridge-addin/RevitBridge/Operator/OperatorToolManifest.cs @@ -162,8 +162,8 @@ internal static class OperatorToolManifest new OperatorToolInfo("Selection", "POST", "/revit/set-selection", "Set Selection", OperatorActionRisk.Low, "Set the current selection to specific element IDs.", "select element id 123"), new OperatorToolInfo("Selection", "POST", "/revit/resolve-room-plan-view", "Resolve Room Plan View", OperatorActionRisk.Low, "Resolve the best plan view for a room or space number and return candidate ranking.", "resolve a room plan view for Level 1"), new OperatorToolInfo("Electrical", "POST", "/revit/plan-dwelling-receptacles", "Plan Dwelling Receptacles", OperatorActionRisk.Low, "Read-only deterministic perimeter receptacle proposal for one room/space using the bounded DWELLING-RECEPTACLES-V1 office profile. Returns grounded wall hosts, opening exclusions, existing-device coverage, proposed placements, assumptions, and manual-review blockers. It does not create devices, circuits, or claim code compliance.", "plan office-standard receptacles in Room 403"), - new OperatorToolInfo("Electrical", "POST", "/revit/plan-room-receptacles-from-analog", "Plan Room Receptacles From Analog", OperatorActionRisk.Low, "Rollback-only native plan and visual preview that discovers a unique same-level furnished analog room, maps its exact receptacle type inventory to corresponding semantic anchors and linked boundary faces, and returns an approval-bound plan hash. It creates no persistent elements.", "plan Room 403 receptacles from the adjacent office-standard analog"), - new OperatorToolInfo("Electrical", "POST", "/revit/apply-room-receptacles-from-analog", "Apply Room Receptacles From Analog", OperatorActionRisk.High, "High-risk atomic apply of a previously hashed native analog receptacle plan. Recomputes the plan, places exact face-hosted family types, verifies room/type/host/position/orientation readback, and refuses stale or nonempty targets.", "apply the approved Room 403 analog receptacle plan"), + new OperatorToolInfo("Electrical", "POST", "/revit/plan-room-receptacles-from-analog", "Plan Room Receptacles From Analog", OperatorActionRisk.Low, "Rollback-only native plan and visual preview that discovers a unique same-level furnished analog room, maps its exact receptacle type inventory to corresponding semantic anchors and linked boundary faces, and returns an approval-bound plan hash. circuitMode=match_source_system additionally proves each source has exactly one panel-assigned power system, temporarily joins each proposed device through the real ElectricalSystem, and returns exact system-ID plus factual panel/circuit/load readback before rollback. Default circuitMode=none creates no circuit membership.", "plan Room 403 receptacles and match the exact source circuits from Room 405"), + new OperatorToolInfo("Electrical", "POST", "/revit/apply-room-receptacles-from-analog", "Apply Room Receptacles From Analog", OperatorActionRisk.High, "High-risk atomic apply of a previously hashed native analog receptacle plan. Recomputes the plan, places exact face-hosted family types, verifies room/type/host/position/orientation readback, and refuses stale or nonempty targets. When the approved plan used circuitMode=match_source_system, the apply atomically joins and verifies the exact source ElectricalSystem IDs and reports factual before/after loads without making capacity or code-compliance claims.", "apply the approved Room 403 analog receptacle and source-circuit plan"), new OperatorToolInfo("Types", "POST", "/revit/list-element-types", "List Element Types", OperatorActionRisk.Low, "Type list + family-type maintenance helper. Actions: list (default), rename_types, purge_unused_in_family. List supports {category|categories, exactName?, nameContains?, familyNameContains?, includeParameters?, limit?, exportCsv?, outputFolder?, fileName?}. Rename supports {action:\"rename_types\",familyName,searchPattern,replaceWith?,regexIgnoreCase?,maxEdits?,category|categories?,dryRun?}. Purge supports {action:\"purge_unused_in_family\",familyName,maxDelete?,category|categories?,dryRun?}.", "dry-run rename family types by regex"), new OperatorToolInfo("Types", "POST", "/revit/resolve-element-type", "Resolve Element Type", OperatorActionRisk.Low, "Resolve an ElementType by name without listing everything. Request: {category, typeName, familyName?, exact?, includeParameters?, cacheBust?}.", "resolve wall type \"Generic - 4\\\"\""), @@ -213,7 +213,7 @@ internal static class OperatorToolManifest new OperatorToolInfo("Modeling", "POST", "/revit/place-family-instance-on-host", "Place Family Instance On Host", OperatorActionRisk.High, "Place a family instance onto a specific host using an exemplar/type plus host-relative offsets, wall-chainage inputs, optional room-side link-host context, orientation matching, optional electrical-circuit matching from the source exemplar, parameter copy, and preview export. Body: {sourceElementId?|familySymbolId?|familyName+symbolName,hostElementId,roomId?|roomNumber?,roomSide?,referenceElementId?,pointXyz?,alongHostOffsetFt?,targetChainageFt?,targetNormalizedChainage?,elevationFt?|elevationDeltaFt?,matchOrientationFromSource?,orientationSourceElementId?,copyRotation?,copyFacingHandState?,matchElectricalCircuitFromSource?,requireElectricalCircuitMatch?,parameterNamesToCopy?,parameterOverrides?,dryRun?,includePreviewImage?,previewViewId?,previewImageSize?,focusPaddingFt?}.", "dry-run place the same receptacle type on this wall using the adjacent device as the source and keep it on the same circuit"), new OperatorToolInfo("Modeling", "POST", "/revit/create-similar-from-instance", "Create Similar From Instance", OperatorActionRisk.High, "Create one or more hosted copies from an exemplar instance, preserving hosting behavior/orientation while supporting exact host slots via wall-chainage or explicit point placements plus preview. Optionally match the source exemplar's electrical circuit. Body: {exemplarElementId,hostElementId?,roomId?|roomNumber?,roomSide?,referenceElementId?,alongHostOffsetsFt?|placements?,matchOrientationFromSource?,orientationSourceElementId?,copyRotation?,copyFacingHandState?,matchElectricalCircuitFromSource?,requireElectricalCircuitMatch?,parameterNamesToCopy?,parameterOverrides?,dryRun?,includePreviewImage?,previewViewId?,previewImageSize?,focusPaddingFt?}.", "dry-run create two similar receptacles from this exemplar on the same wall and keep them on the same circuit"), new OperatorToolInfo("Modeling", "POST", "/revit/adjust-hosted-instance-on-host", "Adjust Hosted Instance On Host", OperatorActionRisk.High, "Move and/or reorient an existing hosted family instance along its resolved host frame using chainage, relative host offsets, or explicit target points, with optional orientation matching, electrical-circuit preservation/reassignment, deterministic placement validation, and preview export. Linked-face hosted instances are atomically recreated on the resolved face and the original is deleted only after validation; the response reports originalElementId, the current elementId, and replacementStrategy. Body: {elementId,roomId?|roomNumber?,roomSide?,orientationSourceElementId?,electricalCircuitSourceElementId?,matchOrientationFromSource?,matchElectricalCircuitFromSource?,requireElectricalCircuitMatch?,copyRotation?,copyFacingHandState?,pointXyz?,alongHostDeltaFt?,targetChainageFt?,targetNormalizedChainage?,rotateToHostRelativeDegrees?,dryRun?,includePreviewImage?,previewViewId?,previewImageSize?,focusPaddingFt?,label?}.", "adjust this created receptacle along the wall, preserve its circuit, and preview the correction"), - new OperatorToolInfo("Modeling", "POST", "/revit/assign-electrical-circuit", "Assign Electrical Circuit", OperatorActionRisk.High, "Assign or preflight electrical circuit membership for family instances. Prefer sourceElementId to join the same real ElectricalSystem as an exemplar; panelName/circuitNumber falls back to writable instance parameters when Revit exposes them. Body: {elementIds,sourceElementId?,panelName?,circuitNumber?,dryRun?,confirm?,parameterOnlyFallback?}.", "dry-run assign device 22222 to the same circuit as exemplar 11111"), +new OperatorToolInfo("Modeling", "POST", "/revit/assign-electrical-circuit", "Assign Electrical Circuit", OperatorActionRisk.High, "Assign or preflight electrical circuit membership for family instances. sourceElementId requires exactly one source power system, performs atomic real ElectricalSystem reassignment, and verifies the exact system ID. panelName/circuitNumber are non-membership labels and are attempted only when parameterOnlyFallback:true is explicitly supplied without sourceElementId. Body: {elementIds,sourceElementId?,panelName?,circuitNumber?,dryRun?,confirm?,parameterOnlyFallback?}.", "dry-run assign device 22222 to the exact power circuit of exemplar 11111"), new OperatorToolInfo("Modeling", "POST", "/revit/load-family", "Load Family", OperatorActionRisk.High, "Load an RFA into the project.", "load a family"), new OperatorToolInfo("Modeling", "POST", "/revit/create-family-from-template", "Create Family From Template", OperatorActionRisk.High, "Create a new family from a template.", "create a family from template"), new OperatorToolInfo("Modeling", "POST", "/revit/duplicate-view", "Duplicate View", OperatorActionRisk.High, "Duplicate a view.", "duplicate the active view"), diff --git a/apps/revit-bridge-addin/RevitBridge/Tooling/tool_examples.json b/apps/revit-bridge-addin/RevitBridge/Tooling/tool_examples.json index 53b6880..8a4e5fc 100644 --- a/apps/revit-bridge-addin/RevitBridge/Tooling/tool_examples.json +++ b/apps/revit-bridge-addin/RevitBridge/Tooling/tool_examples.json @@ -4056,6 +4056,8 @@ "name": "Preview a room receptacle layout from a discovered analog", "request": { "targetRoomNumber": "403", + "sourceRoomNumber": "405", + "circuitMode": "match_source_system", "includePreviewImage": true }, "expected_response_shape": { @@ -4079,6 +4081,7 @@ "request": { "targetRoomNumber": "403", "sourceRoomNumber": "405", + "circuitMode": "match_source_system", "planHash": "sha256", "includePreviewImage": true }, @@ -8500,13 +8503,12 @@ 22222 ], "sourceElementId": 11111, - "dryRun": true, - "parameterOnlyFallback": true + "dryRun": true }, "expected_response_shape": { - "schema": "operator.assign_electrical_circuit.v1", + "schema": "operator.assign_electrical_circuit.v2", "applied": false, - "mode": "source_system_first_then_parameter_fallback", + "mode": "strict_exact_source_power_system", "results": [ { "elementId": 22222, @@ -8537,10 +8539,10 @@ "parameterOnlyFallback": true }, "expected_response_shape": { - "schema": "operator.assign_electrical_circuit.v1", + "schema": "operator.assign_electrical_circuit.v2", "applied": true, - "mode": "parameter_fallback", - "limitation": "When sourceElementId is supplied, the handler attempts real ElectricalSystem reassignment." + "mode": "explicit_parameter_only_fallback", + "limitation": "Direct panel/circuit values are labels only and do not prove circuit membership." } } ]