forked from 1b-agent/opencadstudio-upstream
feat(python): text and dimension styles (doc.text_styles, doc.dim_styles)
Extends TableOperation with text style create/modify, dimension style create/modify (DimStyle fields as a validated JSON object), rename, delete and set-current. Reuses OCS's style in-use scan and reference-following rename (four style_ops methods widened to pub(super)); a renamed text style also updates dimension styles that name it. Real-IPC audit with DWG/DXF persistence and undo, IPC and Python unit tests, docs. Four cadcodec findings are pinned by canaries. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
b7b6e2cef5
commit
aaea15e4ef
11 files changed
+894
-8
No files matched your search
@@ -155,6 +155,36 @@ renaming or deleting layer `0` or `Defpoints`, deleting or freezing the current
|
||||
layer, deleting an externally referenced layer, and modifying with no properties.
|
||||
Making a layer current is a setting, not an undo step.
|
||||
|
||||
### Text and dimension styles: `doc.text_styles`, `doc.dim_styles`
|
||||
|
||||
```python
|
||||
S, D = ocs.active_document.text_styles, ocs.active_document.dim_styles
|
||||
S.create("Title", height=5, width_factor=0.8, oblique=15, font="romans", annotative=True)
|
||||
S.modify("Title", height=6)
|
||||
D.create("Metric", dimscale=2, dimtxt=3.5, dimtxsty="Title") # DimStyle field names
|
||||
D.create("Metric2", copy_from="Metric", dimtxt=4)
|
||||
S.rename("Title", "Heading") # entities, dimension styles and tables follow
|
||||
S.set_current("Heading"); D.set_current("Metric")
|
||||
D.delete("Metric2"); S["Heading"]["height"]; D.current["name"]; S.names()
|
||||
```
|
||||
|
||||
Same guarantees as layers: host-validated, one undo step, a refusal raises
|
||||
`RuntimeError` and changes nothing (making a style current is a setting).
|
||||
Text properties: `height` (0 = each text chooses), `width_factor` (0-100),
|
||||
`oblique` (degrees, within 85), `font`, `big_font`, `backward`, `upside_down`,
|
||||
`vertical`, `annotative`; use `font="arial.ttf"` for a TrueType face, because
|
||||
the codec does not persist a separate family name. Dimension styles use the
|
||||
`DimStyle` field names (`dimscale`, `dimtxt`, `dimasz`, ...); records list all
|
||||
of them. Handles, `xref_*` fields and `name` are host-managed and refused, unknown
|
||||
fields and wrongly typed values are refused, `dimscale` and `dimtxt` must be
|
||||
positive, and `dimtxsty` must name an existing text style. `Standard` is never
|
||||
renamed or deleted; a style that is current or still referenced is never deleted;
|
||||
a case-only rename is refused. Style operations act on the active document.
|
||||
|
||||
Known persistence limits (cadcodec, see `docs/cadcodec-reader-gaps.md`): a DXF
|
||||
save drops the backward/upside-down flags of a text style and does not restore a
|
||||
dimension style's `dimtxsty` name (the handle link survives); DWG keeps both.
|
||||
|
||||
### Historical command-replay experiment (not in the default build)
|
||||
|
||||
The following records an earlier experiment. The bundled build does **not**
|
||||
|
||||
@@ -280,10 +280,128 @@ class _Layers:
|
||||
return self[name]
|
||||
|
||||
|
||||
class _Styles:
|
||||
"""Common lookup, rename, delete and current-style handling for the text
|
||||
and dimension style tables. Every change is validated by the host, is one
|
||||
undo step (making a style current is a setting), and a refusal raises
|
||||
`RuntimeError` without changing the drawing. `Standard` is never renamed
|
||||
or deleted, and a style that is current or in use is never deleted."""
|
||||
|
||||
_KIND = None
|
||||
_RECORDS = None
|
||||
|
||||
def _records(self):
|
||||
return getattr(ocs, self._RECORDS)()
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._records())
|
||||
|
||||
def __len__(self):
|
||||
return len(self._records())
|
||||
|
||||
def __contains__(self, name):
|
||||
wanted = str(name).strip().upper()
|
||||
return any(r["name"].upper() == wanted for r in self._records())
|
||||
|
||||
def __getitem__(self, name):
|
||||
wanted = str(name).strip().upper()
|
||||
for record in self._records():
|
||||
if record["name"].upper() == wanted:
|
||||
return record
|
||||
raise KeyError(name)
|
||||
|
||||
def get(self, name, default=None):
|
||||
try:
|
||||
return self[name]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def names(self):
|
||||
return [r["name"] for r in self._records()]
|
||||
|
||||
@property
|
||||
def current(self):
|
||||
for record in self._records():
|
||||
if record["current"]:
|
||||
return record
|
||||
return None
|
||||
|
||||
@current.setter
|
||||
def current(self, name):
|
||||
self.set_current(name)
|
||||
|
||||
def _run(self, op, name, options=None):
|
||||
ocs.style_operation(self._KIND, op, str(name), options)
|
||||
|
||||
def rename(self, name, new_name):
|
||||
self._run("rename", name, {"to": str(new_name)})
|
||||
return self[new_name]
|
||||
|
||||
def delete(self, name):
|
||||
self._run("delete", name)
|
||||
|
||||
def set_current(self, name):
|
||||
self._run("set_current", name)
|
||||
return self[name]
|
||||
|
||||
|
||||
class _TextStyles(_Styles):
|
||||
"""`ocs.active_document.text_styles`. Properties: `height` (0 = each text
|
||||
chooses), `width_factor`, `oblique` (degrees, within 85), `font` (SHX file),
|
||||
`big_font`, `backward`, `upside_down`, `vertical`, `annotative`. Give a
|
||||
TrueType face as a font file name such as `font="arial.ttf"`: the codec does
|
||||
not persist a separate TrueType family name, so it is not exposed."""
|
||||
|
||||
_KIND = "text"
|
||||
_RECORDS = "text_style_records"
|
||||
_KEYS = ("height", "width_factor", "oblique", "font", "big_font",
|
||||
"backward", "upside_down", "vertical", "annotative")
|
||||
|
||||
def _options(self, properties):
|
||||
unknown = [k for k in properties if k not in self._KEYS]
|
||||
if unknown:
|
||||
raise TypeError("unknown text style propert%s: %s" % ("y" if len(unknown) == 1 else "ies", ", ".join(sorted(unknown))))
|
||||
return {k: v for k, v in properties.items() if v is not None}
|
||||
|
||||
def create(self, name, **properties):
|
||||
self._run("create", name, self._options(properties))
|
||||
return self[name]
|
||||
|
||||
def modify(self, name, **properties):
|
||||
self._run("modify", name, self._options(properties))
|
||||
return self[name]
|
||||
|
||||
|
||||
class _DimStyles(_Styles):
|
||||
"""`ocs.active_document.dim_styles`. Records and properties use the DimStyle
|
||||
field names (`dimscale`, `dimtxt`, `dimasz`, `dimtxsty` ...); handles and
|
||||
xref fields are host-managed and refused. `create(..., copy_from="Other")`
|
||||
starts from an existing style."""
|
||||
|
||||
_KIND = "dim"
|
||||
_RECORDS = "dim_style_records"
|
||||
|
||||
def _options(self, properties):
|
||||
return {k: v for k, v in properties.items() if v is not None or k == "dimclrd_true_color"}
|
||||
|
||||
def create(self, name, copy_from=None, **properties):
|
||||
options = self._options(properties)
|
||||
if copy_from is not None:
|
||||
options["copy_from"] = str(copy_from)
|
||||
self._run("create", name, options)
|
||||
return self[name]
|
||||
|
||||
def modify(self, name, **properties):
|
||||
self._run("modify", name, self._options(properties))
|
||||
return self[name]
|
||||
|
||||
|
||||
class _Document:
|
||||
def __init__(self):
|
||||
self._pending = None
|
||||
self.layers = _Layers()
|
||||
self.text_styles = _TextStyles()
|
||||
self.dim_styles = _DimStyles()
|
||||
self.entities = _Entities(self)
|
||||
self.solids = _Solids(self)
|
||||
|
||||
|
||||
@@ -952,6 +952,7 @@ mod ocs {
|
||||
Ok(vm.ctx.new_list(output).into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
fn layer_options(
|
||||
name: String,
|
||||
options: &rustpython_vm::builtins::PyDictRef,
|
||||
@@ -1022,6 +1023,7 @@ mod ocs {
|
||||
/// Layer-table change through the host: `op` is `create`, `modify`,
|
||||
/// `rename` (`options={"to": ...}`), `delete` (`options={"erase_objects": bool}`)
|
||||
/// or `set_current`. Returns the layer's handle.
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
#[pyfunction]
|
||||
fn layer_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult<u64> {
|
||||
use ocs_plugin_api::host::TableOperation;
|
||||
@@ -1056,7 +1058,220 @@ mod ocs {
|
||||
.map_err(|error| vm.new_runtime_error(format!("ocs.layer_operation: {error}")))
|
||||
}
|
||||
|
||||
/// Convert a Python value (None, bool, int, float, str, list/tuple, dict
|
||||
/// with string keys) to JSON.
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
fn py_to_json(value: &PyObjectRef, vm: &VirtualMachine) -> PyResult<serde_json::Value> {
|
||||
use rustpython_vm::builtins::{PyDict, PyFloat, PyInt, PyList, PyStr, PyTuple};
|
||||
use rustpython_vm::AsObject;
|
||||
if vm.is_none(value) {
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
if let Ok(flag) = value.clone().try_into_value::<bool>(vm) {
|
||||
if value.class().is(vm.ctx.types.bool_type) {
|
||||
return Ok(serde_json::Value::Bool(flag));
|
||||
}
|
||||
}
|
||||
if value.downcast_ref::<PyInt>().is_some() {
|
||||
return Ok(serde_json::Value::from(value.clone().try_into_value::<i64>(vm)?));
|
||||
}
|
||||
if value.downcast_ref::<PyFloat>().is_some() {
|
||||
let number = value.clone().try_into_value::<f64>(vm)?;
|
||||
return serde_json::Number::from_f64(number)
|
||||
.map(serde_json::Value::Number)
|
||||
.ok_or_else(|| vm.new_value_error("ocs: numbers must be finite".to_owned()));
|
||||
}
|
||||
if value.downcast_ref::<PyStr>().is_some() {
|
||||
return Ok(serde_json::Value::String(value.clone().try_into_value::<String>(vm)?));
|
||||
}
|
||||
if let Some(list) = value.downcast_ref::<PyList>() {
|
||||
let items: Vec<PyObjectRef> = list.borrow_vec().to_vec();
|
||||
return items.iter().map(|item| py_to_json(item, vm)).collect::<PyResult<Vec<_>>>().map(serde_json::Value::Array);
|
||||
}
|
||||
if let Some(tuple) = value.downcast_ref::<PyTuple>() {
|
||||
return tuple.iter().map(|item| py_to_json(item, vm)).collect::<PyResult<Vec<_>>>().map(serde_json::Value::Array);
|
||||
}
|
||||
if let Some(dict) = value.downcast_ref::<PyDict>() {
|
||||
let mut map = serde_json::Map::new();
|
||||
for key in dict.keys_vec() {
|
||||
let name = key.clone().try_into_value::<String>(vm)?;
|
||||
let item = dict.get_item(&*key, vm)?;
|
||||
map.insert(name, py_to_json(&item, vm)?);
|
||||
}
|
||||
return Ok(serde_json::Value::Object(map));
|
||||
}
|
||||
Err(vm.new_type_error("ocs: unsupported value in a style property".to_owned()))
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
fn style_kind(kind: &str, vm: &VirtualMachine) -> PyResult<ocs_plugin_api::host::TableStyleKind> {
|
||||
match kind {
|
||||
"text" => Ok(ocs_plugin_api::host::TableStyleKind::Text),
|
||||
"dim" => Ok(ocs_plugin_api::host::TableStyleKind::Dim),
|
||||
other => Err(vm.new_value_error(format!("ocs.style_operation: unknown style kind {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text/dimension style change through the host. `kind` is `text` or `dim`;
|
||||
/// `op` is `create`, `modify`, `rename` (`{"to": ...}`), `delete` or
|
||||
/// `set_current`. Text options: `height`, `width_factor`, `oblique`
|
||||
/// (degrees), `font`, `big_font`, `backward`,
|
||||
/// `upside_down`, `vertical`, `annotative`. Dimension options are DimStyle
|
||||
/// field names (plus `copy_from` on create). Returns the style's handle.
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
#[pyfunction]
|
||||
fn style_operation(kind: String, op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult<u64> {
|
||||
use ocs_plugin_api::host::{TableOperation, TextStyleConfig};
|
||||
let style_kind = style_kind(&kind, vm)?;
|
||||
let options = if vm.is_none(&options) {
|
||||
vm.ctx.new_dict()
|
||||
} else {
|
||||
options.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?
|
||||
};
|
||||
let operation = match (op.as_str(), kind.as_str()) {
|
||||
("rename", _) => {
|
||||
ensure_known_entity_keys(&options, "style rename", &["to"], vm)?;
|
||||
let to = options
|
||||
.get_item_opt("to", vm)?
|
||||
.ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))?
|
||||
.try_into_value::<String>(vm)?;
|
||||
TableOperation::StyleRename { kind: style_kind, from: name, to }
|
||||
}
|
||||
("delete", _) => {
|
||||
ensure_known_entity_keys(&options, "style delete", &[], vm)?;
|
||||
TableOperation::StyleDelete { kind: style_kind, name }
|
||||
}
|
||||
("set_current", _) => TableOperation::StyleSetCurrent { kind: style_kind, name },
|
||||
("create" | "modify", "text") => {
|
||||
ensure_known_entity_keys(
|
||||
&options,
|
||||
"text style",
|
||||
&["height", "width_factor", "oblique", "font", "big_font", "backward", "upside_down", "vertical", "annotative"],
|
||||
vm,
|
||||
)?;
|
||||
let present = |key: &str| -> PyResult<Option<PyObjectRef>> {
|
||||
Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v)))
|
||||
};
|
||||
let number = |key: &str| -> PyResult<Option<f64>> {
|
||||
present(key)?.map(|v| py_number_to_f64(v, vm)).transpose()
|
||||
};
|
||||
let text = |key: &str| -> PyResult<Option<String>> {
|
||||
present(key)?.map(|v| v.try_into_value::<String>(vm)).transpose()
|
||||
};
|
||||
let flag = |key: &str| -> PyResult<Option<bool>> {
|
||||
present(key)?.map(|v| v.try_into_value::<bool>(vm)).transpose()
|
||||
};
|
||||
let config = TextStyleConfig {
|
||||
name,
|
||||
height: number("height")?,
|
||||
width_factor: number("width_factor")?,
|
||||
oblique_angle: number("oblique")?.map(f64::to_radians),
|
||||
font_file: text("font")?,
|
||||
big_font_file: text("big_font")?,
|
||||
backward: flag("backward")?,
|
||||
upside_down: flag("upside_down")?,
|
||||
vertical: flag("vertical")?,
|
||||
annotative: flag("annotative")?,
|
||||
..Default::default()
|
||||
};
|
||||
if op == "create" {
|
||||
TableOperation::TextStyleCreate { config }
|
||||
} else {
|
||||
TableOperation::TextStyleModify { config }
|
||||
}
|
||||
}
|
||||
("create" | "modify", "dim") => {
|
||||
let copy_from = if op == "create" {
|
||||
options.get_item_opt("copy_from", vm)?.filter(|v| !vm.is_none(v))
|
||||
.map(|v| v.try_into_value::<String>(vm)).transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let properties = vm.ctx.new_dict();
|
||||
for key in options.keys_vec() {
|
||||
if key.clone().try_into_value::<String>(vm)? != "copy_from" {
|
||||
properties.set_item(&*key, options.get_item(&*key, vm)?, vm)?;
|
||||
}
|
||||
}
|
||||
let properties = py_to_json(&PyObjectRef::from(properties), vm)?.to_string();
|
||||
if op == "create" {
|
||||
TableOperation::DimStyleCreate { name, copy_from, properties }
|
||||
} else {
|
||||
TableOperation::DimStyleModify { name, properties }
|
||||
}
|
||||
}
|
||||
(other, _) => return Err(vm.new_value_error(format!("ocs.style_operation: unknown operation {other:?}"))),
|
||||
};
|
||||
let result = host_ctx::with_host(|host| host.table_operation(operation))
|
||||
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
|
||||
result
|
||||
.map(|handle| handle.value())
|
||||
.map_err(|error| vm.new_runtime_error(format!("ocs.style_operation: {error}")))
|
||||
}
|
||||
|
||||
/// Every text style with its properties (`oblique` in degrees).
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
#[pyfunction]
|
||||
fn text_style_records(vm: &VirtualMachine) -> PyResult<PyObjectRef> {
|
||||
let rows = host_ctx::with_host(|host| {
|
||||
let document = host.document();
|
||||
let current = document.header.current_text_style_name.to_uppercase();
|
||||
document
|
||||
.text_styles
|
||||
.iter()
|
||||
.map(|style| (style.clone(), style.name.to_uppercase() == current))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
|
||||
let mut output = Vec::with_capacity(rows.len());
|
||||
for (style, is_current) in rows {
|
||||
let dict = vm.ctx.new_dict();
|
||||
dict.set_item("handle", vm.new_pyobj(style.handle.value()), vm)?;
|
||||
dict.set_item("name", vm.new_pyobj(style.name.clone()), vm)?;
|
||||
dict.set_item("height", vm.new_pyobj(style.height), vm)?;
|
||||
dict.set_item("width_factor", vm.new_pyobj(style.width_factor), vm)?;
|
||||
dict.set_item("oblique", vm.new_pyobj(style.oblique_angle.to_degrees()), vm)?;
|
||||
dict.set_item("font", vm.new_pyobj(style.font_file.clone()), vm)?;
|
||||
dict.set_item("big_font", vm.new_pyobj(style.big_font_file.clone()), vm)?;
|
||||
dict.set_item("backward", vm.new_pyobj(style.flags.backward), vm)?;
|
||||
dict.set_item("upside_down", vm.new_pyobj(style.flags.upside_down), vm)?;
|
||||
dict.set_item("vertical", vm.new_pyobj(style.is_vertical), vm)?;
|
||||
dict.set_item("annotative", vm.new_pyobj(style.annotative), vm)?;
|
||||
dict.set_item("current", vm.new_pyobj(is_current), vm)?;
|
||||
output.push(dict.into());
|
||||
}
|
||||
Ok(vm.ctx.new_list(output).into())
|
||||
}
|
||||
|
||||
/// Every dimension style with all of its DimStyle fields plus `current`.
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
#[pyfunction]
|
||||
fn dim_style_records(vm: &VirtualMachine) -> PyResult<PyObjectRef> {
|
||||
let rows = host_ctx::with_host(|host| {
|
||||
let document = host.document();
|
||||
let current = document.header.current_dimstyle_name.to_uppercase();
|
||||
document
|
||||
.dim_styles
|
||||
.iter()
|
||||
.map(|style| {
|
||||
let mut value = serde_json::to_value(style).unwrap_or(serde_json::Value::Null);
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.insert("current".to_owned(), (style.name.to_uppercase() == current).into());
|
||||
}
|
||||
value
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
|
||||
let output = rows
|
||||
.into_iter()
|
||||
.map(|value| snapshot_value_to_py(value, vm))
|
||||
.collect::<PyResult<Vec<_>>>()?;
|
||||
Ok(vm.ctx.new_list(output).into())
|
||||
}
|
||||
|
||||
/// Every layer with its full properties, in table order.
|
||||
#[cfg(feature = "experimental-host-model")]
|
||||
#[pyfunction]
|
||||
fn layer_records(vm: &VirtualMachine) -> PyResult<PyObjectRef> {
|
||||
use acadrust::types::{LineWeight, Transparency};
|
||||
|
||||
@@ -36,6 +36,9 @@ class DocumentModelTests(unittest.TestCase):
|
||||
poll_input=lambda token: {"status": "point", "point": [1, 2, 3]},
|
||||
layer_records=lambda: [{"name": "0", "current": True}, {"name": "Walls", "current": False}],
|
||||
layer_operation=lambda op, name, options: self.calls.append((op, name, options)) or 9,
|
||||
text_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Title", "Notes")],
|
||||
dim_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Metric")],
|
||||
style_operation=lambda kind, op, name, options: self.calls.append((kind, op, name, options)) or 9,
|
||||
)
|
||||
namespace = {"ocs": self.ocs}
|
||||
exec(MODEL.read_text(), namespace)
|
||||
@@ -72,6 +75,25 @@ class DocumentModelTests(unittest.TestCase):
|
||||
layers.create("New", colour=1)
|
||||
self.assertEqual(len(self.calls), 2)
|
||||
|
||||
def test_text_and_dim_styles(self):
|
||||
text, dim = self.doc.text_styles, self.doc.dim_styles
|
||||
self.assertEqual(text.current["name"], "Standard")
|
||||
self.assertEqual(text["TITLE"]["name"], "Title")
|
||||
text.create("Notes", height=2.5, font=None, oblique=10)
|
||||
self.assertEqual(self.calls[-1], ("text", "create", "Notes", {"height": 2.5, "oblique": 10}))
|
||||
with self.assertRaises(TypeError):
|
||||
text.create("Bad", true_type_font="Arial")
|
||||
with self.assertRaises(TypeError):
|
||||
text.modify("Title", fnt="x")
|
||||
dim.create("Metric", copy_from="Standard", dimscale=2)
|
||||
self.assertEqual(self.calls[-1], ("dim", "create", "Metric", {"dimscale": 2, "copy_from": "Standard"}))
|
||||
dim.modify("Standard", dimtxt=3)
|
||||
self.assertEqual(self.calls[-1], ("dim", "modify", "Standard", {"dimtxt": 3}))
|
||||
dim.delete("Metric")
|
||||
self.assertEqual(self.calls[-1], ("dim", "delete", "Metric", None))
|
||||
dim.set_current("Standard")
|
||||
self.assertEqual(self.calls[-1], ("dim", "set_current", "Standard", None))
|
||||
|
||||
def test_create_and_delete_use_document_model(self):
|
||||
line = self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0},
|
||||
end={"x": 10, "y": 0, "z": 0})
|
||||
|
||||
Reference in new issue
Block a user