From 33870310383fbbd8ab7908b05b3bde49135c95cf Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 07:33:19 +0200 Subject: [PATCH] Complete Tolerance host model coverage --- crates/ocs_plugin_api/build.rs | 4 +- .../entity_coverage_policy.json | 6 +- crates/ocs_plugin_api/src/entity_coverage.rs | 75 ++++++++++- docs/plugin-host-model-completion-plan.md | 2 + docs/plugin-host-model-coverage-ledger.md | 6 +- src/app/plugin_host.rs | 122 +++++++++++++++++- 6 files changed, 205 insertions(+), 10 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 782b81cce..82df52245 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -583,7 +583,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { ("Solid", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "normal" | "thickness") | ("Face3D", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "invisible_edges") | ("Insert", "insert_point" | "x_scale" | "y_scale" | "z_scale" | "rotation" | "normal" | - "column_count" | "row_count" | "column_spacing" | "row_spacing")) { + "column_count" | "row_count" | "column_spacing" | "row_spacing") | + ("Tolerance", "insertion_point" | "direction" | "normal" | "text" | + "dimension_style_name" | "text_height" | "dimension_gap")) { "transaction_geometry" } else { "type_conversion_only" }; properties.push(PropertyCoverage { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index cff46a247..33cdec8a2 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -18,10 +18,12 @@ "XLine": ["layer", "base_point", "direction"], "Solid": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "normal", "thickness", "is_trace"], "Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"], - "Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"] + "Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"], + "Tolerance": ["layer", "insertion_point", "direction", "normal", "text", "dimension_style_name", "text_height", "dimension_gap"] }, "readable": { - "Insert": ["block_name"] + "Insert": ["block_name"], + "Tolerance": ["dimension_style_handle"] }, "aliases": { "Polyline": {"closed": "flags"}, diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 3f8131c1d..b76843345 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -79,6 +79,17 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() return Err("Face3D.invisible_edges has unknown bits".into()); } } + EntityType::Tolerance(value) => { + finite_vector("Tolerance.insertion_point", &value.insertion_point)?; + unit_direction("Tolerance.direction", &value.direction)?; + solid_normal("Tolerance.normal", &value.normal)?; + if value.text.trim().is_empty() { return Err("Tolerance.text is empty".into()); } + if value.dimension_style_name.trim().is_empty() { return Err("Tolerance.dimension_style_name is empty".into()); } + if !value.text_height.is_finite() || value.text_height <= 0.0 { + return Err("Tolerance.text_height must be finite and greater than zero".into()); + } + if !value.dimension_gap.is_finite() { return Err("Tolerance.dimension_gap must be finite".into()); } + } _ => {} } Ok(()) @@ -201,11 +212,49 @@ pub fn validate_entity_mutation( return Err("Insert array counts must be greater than zero".into()); } } + (EntityType::Tolerance(old), EntityType::Tolerance(new)) => { + changed3("Tolerance.insertion_point", &old.insertion_point, &new.insertion_point)?; + if !same3(&old.direction, &new.direction) { unit_direction("Tolerance.direction", &new.direction)?; } + if !same3(&old.normal, &new.normal) { solid_normal("Tolerance.normal", &new.normal)?; } + if old.text != new.text && new.text.trim().is_empty() { return Err("Tolerance.text is empty".into()); } + if old.dimension_style_name != new.dimension_style_name && new.dimension_style_name.trim().is_empty() { + return Err("Tolerance.dimension_style_name is empty".into()); + } + if old.text_height.to_bits() != new.text_height.to_bits() + && (!new.text_height.is_finite() || new.text_height <= 0.0) { + return Err("Tolerance.text_height must be finite and greater than zero".into()); + } + if old.dimension_gap.to_bits() != new.dimension_gap.to_bits() && !new.dimension_gap.is_finite() { + return Err("Tolerance.dimension_gap must be finite".into()); + } + if old.dimension_style_handle != new.dimension_style_handle + && !(old.dimension_style_name != new.dimension_style_name && new.dimension_style_handle.is_none()) { + return Err("Tolerance.dimension_style_handle is read-only".into()); + } + } _ => {} } Ok(()) } +/// Validate references which require the surrounding document. Keeping this +/// in the host API gives every scripting adapter the same reference rules. +#[cfg(feature = "host")] +pub fn validate_canvas_entity_references( + document: &crate::host::CadDocument, + entity: &crate::host::EntityType, +) -> Result<(), String> { + use crate::host::EntityType; + if let EntityType::Tolerance(value) = entity { + let found = value.dimension_style_handle.filter(|handle| !handle.is_null()).map_or_else( + || document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(value.dimension_style_name.trim())), + |handle| document.dim_styles.iter().any(|style| style.handle == handle), + ); + if !found { return Err(format!("Tolerance dimension style {:?} does not exist", value.dimension_style_name)); } + } + Ok(()) +} + /// Clone a canvas entity with a new layer, preserving its other snapshot /// fields. Internal records and opaque fallbacks are excluded. #[cfg(feature = "host")] @@ -256,7 +305,7 @@ mod tests { if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(), "Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" | "Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" | - "Ray" | "XLine" | "Solid" | "Face3D" | "Insert") { + "Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance") { assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); } @@ -288,9 +337,33 @@ mod tests { "Insert.rotation".to_owned(), "Insert.normal".to_owned(), "Insert.column_count".to_owned(), "Insert.row_count".to_owned(), "Insert.column_spacing".to_owned(), "Insert.row_spacing".to_owned(), + "Tolerance.insertion_point".to_owned(), "Tolerance.direction".to_owned(), + "Tolerance.normal".to_owned(), "Tolerance.text".to_owned(), + "Tolerance.dimension_style_name".to_owned(), "Tolerance.text_height".to_owned(), + "Tolerance.dimension_gap".to_owned(), ])); } + #[cfg(feature = "host")] + #[test] + fn tolerance_geometry_and_style_references_are_validated() { + use crate::host::acadrust::{self, types::Vector3}; + let document = acadrust::CadDocument::new(); + let mut tolerance = acadrust::entities::Tolerance::with_text( + Vector3::new(1.0, 2.0, 0.0), "{\\Fgdt;p}%%v0.1"); + let entity = acadrust::EntityType::Tolerance(tolerance.clone()); + validate_new_canvas_entity(&entity).unwrap(); + validate_canvas_entity_references(&document, &entity).unwrap(); + tolerance.direction = Vector3::new(2.0, 0.0, 0.0); + assert!(validate_new_canvas_entity(&acadrust::EntityType::Tolerance(tolerance.clone())) + .unwrap_err().contains("unit vector")); + tolerance.direction = Vector3::UNIT_X; + tolerance.dimension_style_name = "Missing".into(); + assert!(validate_canvas_entity_references( + &document, &acadrust::EntityType::Tolerance(tolerance)) + .unwrap_err().contains("does not exist")); + } + #[test] fn layer_write_coverage_matches_canvas_scope() { let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 3b9c9db86..b2baffcf2 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -105,3 +105,5 @@ Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate - The opt-in `staged_python_point_pick_and_cancel_over_real_ipc` test passes with the same runner. It requests point and entity picks through Python, drives the app's active-command point/object-pick callbacks, polls the returned tokens through a later Python run, and verifies Enter cancellation. The callbacks are invoked by the test harness, not by a physical GUI pointer event; live GUI verification remains open. - The opt-in `staged_python_tabs_isolate_tokens_and_notifications` test passes with two real OCS document tabs and the staged runner. A token created in tab 0 cannot be observed or consumed in tab 1 before or after completion. Host-to-plugin DrawingChanged notifications for both tab ids are delivered over IPC and drained only by their owning tab. A 257-event burst reports overflow, and `DocumentTabClosed` discards queued events for that tab. - Added `doc.create_entity(kind, **properties)` and `doc.delete_entity(entity_or_handle)` to the high-level Python document model. Unit tests cover forwarding and managed identity fields. The real staged-plugin Line lifecycle now uses these methods rather than low-level `ocs.add`/`remove_entity`. +- Closed the first new-kind increment for `Tolerance`. The host coverage catalog now maps insertion point, direction, normal, text, dimension style name, text height, and gap; validates geometry and document style references; keeps the style handle read-only; and leaves the undocumented DWG short unmapped. The thin adapter adds generated conversion plus stale style-handle clearing when the style name changes. +- `staged_python_tolerance_lifecycle_over_real_ipc` passes the full C/R/E/D/U/I/W/V/P gate with the actual runner: high-level create/read/transaction edit/delete, live canvas selection, undo/redo, invalid direction and missing style rollback, untouched-field preservation, and DWG/DXF reopen. Tolerance is recorded as **Complete** in the ledger. The queue now continues with `Shape`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index c12e1419f..a770d088d 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,13 +1,13 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). The baseline below is conservative as of 19 September 2026. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **18 of 43 canvas kinds have explicit geometry writes** (17 creatable, plus update-only `Insert`), **1 kind is complete**, and 25 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = portable H7 v5 build. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. | Kind | Baseline | Gate evidence / blocker | |---|---|---| | Point | Mapped | Host transaction test now checks invalid batch rollback, grouped edit undo, and redo; creation/deletion history, real plugin execution, GUI, and DWG/DXF gates remain pending. | -| Line | Integration-tested | High-level document-model create/delete and transaction edit pass through the actual staged-plugin IPC runner; three undo entries, undo/redo, duplicate-handle atomic rejection, and edited DWG/DXF reopen pass in `staged_python_plugin_line_lifecycle_over_real_ipc`. GUI pick/visible-geometry observation and broader invalid-value/unmapped-field preservation checks remain pending; not complete. | +| Line | Integration-tested | High-level document-model create/delete and transaction edit pass through the actual staged-plugin IPC runner; three undo entries, undo/redo, duplicate-handle and nonfinite-coordinate rejection, full typed-entity preservation, Line object-pick routing, and edited DWG/DXF reopen pass. A second editable property outside defining endpoint geometry still needs integration coverage before this row is complete. | | Circle | Mapped | Full gate pending. | | Arc | Mapped | Full gate pending. | | Ellipse | Mapped | Existing focused DWG conversion test; full gate pending. | @@ -23,7 +23,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Solid | Mapped | Focused DWG round trip; full gate pending. | | Face3D | Mapped | Focused DWG round trip; full gate pending. | | Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. | -| Tolerance | Layer-only | Next new kind after integration harness. | +| Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | Layer-only | Requires valid shape/style fixture. | | AttributeDefinition | Layer-only | Requires block-definition fixture. | | AttributeEntity | Layer-only | Requires linked Insert and attribute-sequence fixture. | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 70e2f114c..3a73ce3ba 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -154,6 +154,9 @@ impl<'a> HostSession<'a> { } ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity) .map_err(|error| format!("entity {handle:?}: {error}"))?; + ocs_plugin_api::entity_coverage::validate_canvas_entity_references( + self.document(), entity, + ).map_err(|error| format!("entity {handle:?}: {error}"))?; if self.app.tabs[self.tab].scene.is_layer_locked(handle) { return Err(format!("entity {handle:?} is on a locked layer")); } @@ -641,7 +644,7 @@ fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::comm mod tests { use super::*; use crate::app::OpenCADStudio; - use acadrust::entities::Point; + use acadrust::entities::{Line, Point}; use acadrust::xdata::XDataValue; use ocs_plugin_api::host::DocumentReader; @@ -742,11 +745,23 @@ mod tests { assert_eq!(handles.len(), 1, "Python add did not reach the host"); let handle = handles[0]; assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0)); + let before_edit = host.document().get_entity(handle).unwrap().clone(); dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Move line', [{{'handle':{},'end':{{'x':5.0,'y':0.0,'z':0.0}}}}])", handle.value() )); - assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); + let mut expected_after_edit = before_edit; + let EntityType::Line(expected_line) = &mut expected_after_edit else { unreachable!() }; + expected_line.end.x = 5.0; + assert_eq!(host.document().get_entity(handle), Some(&expected_after_edit), + "partial Python edit must preserve every unmentioned common and geometry field"); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject NaN', [{{'handle':{},'start':{{'x':float('nan'),'y':0.0,'z':0.0}}}}])", + handle.value() + )); + assert_eq!(host.document().get_entity(handle), Some(&expected_after_edit), + "invalid geometry changed the line"); + assert!(host.app.command_line.history.last().unwrap().text.contains("finite coordinates")); dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Reject duplicate', [{{'handle':{},'end':{{'x':8.0,'y':0.0,'z':0.0}}}},{{'handle':{},'end':{{'x':9.0,'y':0.0,'z':0.0}}}}])", handle.value(), handle.value() @@ -778,6 +793,104 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + #[test] + fn staged_python_tolerance_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, + crate::plugin::v4_support::notification_handler(), + ).expect("spawn staged Python plugin"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + dispatch(&mut host, concat!( + "PY_EVAL ocs.active_document.create_entity('Tolerance',", + "insertion_point={'x':1.0,'y':2.0,'z':0.0},", + "text='POSITION%%v0.1').handle" + )); + let handle = host.document().entities().next().expect("Python created a Tolerance") + .common().handle; + let Some(EntityType::Tolerance(created)) = host.document().get_entity(handle) else { + panic!("expected Tolerance"); + }; + assert_eq!(created.insertion_point, acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + assert_eq!(created.dimension_style_name, "Standard"); + let created = created.clone(); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].text", handle.value())); + assert!(host.app.command_line.history.last().unwrap().text.contains("POSITION%%v0.1")); + + let script_path = std::env::temp_dir().join(format!( + "ocs_tolerance_document_model_{}.py", std::process::id())); + std::fs::write(&script_path, format!(concat!( + "doc = ocs.active_document\n", + "tolerance = doc.entities[{}]\n", + "with doc.transaction('Edit tolerance'):\n", + " tolerance.insertion_point = (4.0, 5.0, 0.0)\n", + " tolerance.direction = (0.0, 1.0, 0.0)\n", + " tolerance.text = 'POSITION%%v0.2'\n", + "doc.selection = [tolerance]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); + let _ = std::fs::remove_file(&script_path); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Tolerance(edited) = &expected else { unreachable!() }; + assert_eq!(edited.insertion_point, acadrust::types::Vector3::new(4.0, 5.0, 0.0)); + assert_eq!(edited.direction, acadrust::types::Vector3::UNIT_Y); + assert_eq!(edited.text, "POSITION%%v0.2"); + assert_eq!(edited.common, created.common); + assert_eq!(edited.normal, created.normal); + assert_eq!(edited.dimension_style_name, created.dimension_style_name); + assert_eq!(edited.dimension_style_handle, created.dimension_style_handle); + assert_eq!(edited.text_height, created.text_height); + assert_eq!(edited.dimension_gap, created.dimension_gap); + assert_eq!(edited.dwg_unknown_short, created.dwg_unknown_short); + assert_eq!(host.selection(), vec![handle], "Python selection did not reach the canvas"); + + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject direction', [{{'handle':{},'direction':{{'x':2.0,'y':0.0,'z':0.0}}}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains("unit vector")); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'dimension_style_name':'Missing'}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains("does not exist")); + + let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes)).read().unwrap(); + assert!(matches!(dwg_doc.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2")); + let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); + let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes)) + .unwrap().read().unwrap(); + assert!(matches!(dxf_doc.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2")); + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 1.0 && value.text == "POSITION%%v0.1")); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; @@ -823,7 +936,10 @@ mod tests { let target = { let mut host = HostSession::new(&mut app, 0); - host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(6.0, 7.0, 0.0)))) + host.add_entity(EntityType::Line(Line::from_points( + acadrust::types::Vector3::new(6.0, 7.0, 0.0), + acadrust::types::Vector3::new(9.0, 7.0, 0.0), + ))) }; let entity_token = request(&mut app, "Pick an entity", true); assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick());