diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index d725103746..6bf9625fd2 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -539,6 +539,8 @@ def matchText(self, text: str) -> bool: is2dDisplayable = Property(bool, _is2dDisplayable, constant=True) # Whether the attribute value is displayable in 3d. is3dDisplayable = Property(bool, _is3dDisplayable, constant=True) + # Whether the attribute is a shape or a shape list, managed by the ShapeEditor and ShapeViewer. + hasDisplayableShape = Property(bool, lambda self: False, constant=True) # Attribute link properties and signals inputLinksChanged = Signal() @@ -999,3 +1001,261 @@ def matchText(self, text: str) -> bool: # Override value property value = Property(Variant, Attribute._getValue, _setValue, notify=Attribute.valueChanged) isDefault = Property(bool, lambda self: all(v.isDefault for v in self.value), notify=Attribute.valueChanged) + + +class ShapeAttribute(GroupAttribute): + """ + GroupAttribute subtype tailored for shape-specific handling. + """ + + def __init__(self, node, attributeDesc: desc.Shape, isOutput: bool, + root=None, parent=None): + self._visible = True + self._color = "#2A82DA" # default shape color + super().__init__(node, attributeDesc, isOutput, root, parent) + + # Override + # Signal observationsChanged should be emitted. + def _setValue(self, exportedValue): + super()._setValue(exportedValue) + self.observationsChanged.emit() + + # Override + # Signal observationsChanged should be emitted. + def resetToDefaultValue(self): + super().resetToDefaultValue() + self.observationsChanged.emit() + + # Override + # Signal observationsChanged should be emitted. + def upgradeValue(self, exportedValue): + super().upgradeValue(exportedValue) + self.observationsChanged.emit() + + # Override + # Fix missing link expression serialization. + # Should be remove if link expression serialization is added in GroupAttribute. + def getSerializedValue(self): + if self.isLink: + return self._getInputLink().asLinkExpr() + return {key: attr.getSerializedValue() for key, attr in self._value.objects.items()} + + def getValueAsDict(self) -> dict: + """ + Return the shape attribute value as dict. + For not keyable shape, this is the same as getSerializedValue(). + For keyable shape, the dict is indexed by key. + """ + from collections import defaultdict + outValue = defaultdict(dict) + if self.isLink: + return self._getInputLink().asLinkExpr() + if not self.shapeKeyable: + return super().getSerializedValue() + for attribute in self.value: + if isinstance(attribute, ShapeAttribute): + attributeDict = attribute.getValueAsDict() + if attributeDict: + for key, value in attributeDict.items(): + outValue[key][attribute.name] = value + else: + for pair in attribute.keyValues.pairs: + outValue[str(pair.key)][attribute.name] = pair.value + return dict(outValue) + + def _getVisible(self) -> bool: + """ + Return whether the shape attribute is visible for display. + """ + return self._visible + + def _setVisible(self, visible:bool): + """ + Set the shape attribute visibility for display. + """ + self._visible = visible + self.shapeChanged.emit() + + def _getColor(self) -> str: + """ + Return the shape attribute color for display. + """ + if self.isLink: + return self.inputLink.shapeColor + return self._color + + @raiseIfLink + def _setColor(self, color: str): + """ + Set the shape attribute color for display. + """ + self._color = color + self.shapeChanged.emit() + + def _hasKeyableChilds(self) -> bool: + """ + Whether all child attributes are keyable. + """ + return all((isinstance(attribute, ShapeAttribute) and attribute.shapeKeyable) or + attribute.keyable for attribute in self.value) + + def _getNbObservations(self) -> int: + """ + Return the shape attribute number of observations. + Note: Observation is a value defined across all child attributes for a specific key. + """ + if self.shapeKeyable: + firstAttribute = next(iter(self.value.values())) + if isinstance(firstAttribute, ShapeAttribute): + return firstAttribute.nbObservations + return len(firstAttribute.keyValues.pairs) + return 1 + + def _getObservationKeys(self) -> list: + """ + Return the shape attribute list of observation keys. + Note: Observation is a value defined across all child attributes for a specific key. + """ + if not self.shapeKeyable: + return [] + firstAttribute = next(iter(self.value.values())) + if isinstance(firstAttribute, ShapeAttribute): + return firstAttribute.observationKeys + return firstAttribute.keyValues.getKeys() + + @Slot(str, result=bool) + def hasObservation(self, key: str) -> bool: + """ + Whether the shape attribute has an observation for the given key. + Note: Observation is a value defined across all child attributes for a specific key. + """ + if not self.shapeKeyable: + return True + return all((isinstance(attribute, ShapeAttribute) and attribute.hasObservation(key)) or + (not isinstance(attribute, ShapeAttribute) and attribute.keyValues.hasKey(key)) + for attribute in self.value) + + @raiseIfLink + def removeObservation(self, key: str): + """ + Remove the shape attribute observation for the given key. + Note: Observation is a value defined across all child attributes for a specific key. + """ + for attribute in self.value: + if isinstance(attribute, ShapeAttribute): + attribute.removeObservation(key) + else: + if attribute.keyable: + attribute.keyValues.remove(key) + else: + attribute.resetToDefaultValue() + self.observationsChanged.emit() + + @raiseIfLink + def setObservation(self, key: str, observation: Variant): + """ + Set the shape attribute observation for the given key with the given observation. + Note: Observation is a value defined across all child attributes for a specific key. + """ + for attributeStr, value in observation.items(): + attribute = self.childAttribute(attributeStr) + if attribute is None: + raise RuntimeError(f"Cannot set shape observation for attribute {self._getFullName()} \ + observation is incorrect.") + if isinstance(attribute, ShapeAttribute): + attribute.setObservation(key, value) + else: + if attribute.keyable: + attribute.keyValues.add(key, value) + else: + attribute.value = value + self.observationsChanged.emit() + + @Slot(str, result=Variant) + def getObservation(self, key: str) -> Variant: + """ + Return the shape attribute observation for the given key. + Note: Observation is a value defined across all child attributes for a specific key. + """ + observation = {} + for attribute in self.value: + if isinstance(attribute, ShapeAttribute): + shapeObservation = attribute.getObservation(key) + if shapeObservation is None: + return None + else : + observation[attribute.name] = shapeObservation + else: + if attribute.keyable: + if attribute.keyValues.hasKey(key): + observation[attribute.name] = attribute.keyValues.getValueAtKeyOrDefault(key) + else: + return None + else: + observation[attribute.name] = attribute.value + return observation + + # Properties and signals + # Emitted when a shape related property changed (color, visibility). + shapeChanged = Signal() + # Emitted when a shape observation changed. + observationsChanged = Signal() + # Whether the shape is displayable. + isVisible = Property(bool, _getVisible, _setVisible, notify=shapeChanged) + # The shape color for display. + shapeColor = Property(str, _getColor, _setColor, notify=shapeChanged) + # The shape list of observation keys. + observationKeys = Property(Variant, _getObservationKeys, notify=observationsChanged) + # The number of observation defined. + nbObservations = Property(int, _getNbObservations, notify=observationsChanged) + # Whether the shape attribute childs are keyable. + shapeKeyable = Property(bool,_hasKeyableChilds, constant=True) + # Override hasDisplayableShape property. + hasDisplayableShape = Property(bool, lambda self: True, constant=True) + # Override value property. + value = Property(Variant, Attribute._getValue, _setValue, notify=Attribute.valueChanged) + +class ShapeListAttribute(ListAttribute): + """ + ListAttribute subtype tailored for shape-specific handling. + """ + + def __init__(self, node, attributeDesc: desc.ShapeList, isOutput: bool, + root=None, parent=None): + self._visible = True + super().__init__(node, attributeDesc, isOutput, root, parent) + + def getShapesAsDicts(self): + """ + Return the shape list attribute value as dict. + """ + return [shapeAttribute.getValueAsDict() for shapeAttribute in self.value] + + def _getVisible(self) -> bool: + """ + Return whether the shape list is visible for display. + """ + if self.isLink: + return self.inputLink.isVisible + return self._visible + + def _setVisible(self, visible:bool): + """ + Set the shape visibility for display. + """ + if self.isLink: + self.inputLink.isVisible = visible + else: + self._visible = visible + for attribute in self.value: + if isinstance(attribute, ShapeAttribute): + attribute.isVisible = visible + self.shapeListChanged.emit() + + # Properties and signals + # Emitted when a shape list related property changed. + shapeListChanged = Signal() + # Whether the shape list is displayable. + isVisible = Property(bool, _getVisible, _setVisible, notify=shapeListChanged) + # Override hasDisplayableShape property. + hasDisplayableShape = Property(bool, lambda self: True, constant=True) \ No newline at end of file diff --git a/meshroom/core/desc/__init__.py b/meshroom/core/desc/__init__.py index 8623b28051..466d8daa77 100644 --- a/meshroom/core/desc/__init__.py +++ b/meshroom/core/desc/__init__.py @@ -11,6 +11,15 @@ PushButtonParam, StringParam, ) +from .shapeAttribute import ( + Shape, + ShapeList, + Size2d, + Point2d, + Line2d, + Rectangle, + Circle +) from .computation import ( DynamicNodeSize, Level, diff --git a/meshroom/core/desc/shapeAttribute.py b/meshroom/core/desc/shapeAttribute.py new file mode 100644 index 0000000000..a678bcc09a --- /dev/null +++ b/meshroom/core/desc/shapeAttribute.py @@ -0,0 +1,132 @@ +from meshroom.core.desc import ListAttribute, GroupAttribute, FloatParam + +class Shape(GroupAttribute): + """ + Base attribute for all Shape attribute. + Countains several attributes (inherit from GroupAttribute). + """ + def __init__(self, groupDesc, name, label, description, group="allParams", advanced=False, semantic="", + enabled=True, visible=True, exposed=False): + # GroupAttribute constructor + super(Shape, self).__init__(groupDesc=groupDesc, name=name, label=label, description=description, + group=group, advanced=advanced, semantic=semantic, + enabled=enabled, visible=visible, exposed=exposed) + + def getInstanceType(self): + """ + Return the correct Attribute instance corresponding to the description. + """ + # Import within the method to prevent cyclic dependencies + from meshroom.core.attribute import ShapeAttribute + return ShapeAttribute + +class ShapeList(ListAttribute): + """ + List attribute of Shape attribute. + Countains several attributes (inherit from ListAttribute). + """ + def __init__(self, shape: Shape, name, label, description, group="allParams", advanced=False, semantic="", + enabled=True, visible=True, exposed=False): + # ListAttribute constructor + super(ShapeList, self).__init__(elementDesc=shape, name=name, label=label, description=description, + group=group, advanced=advanced, semantic=semantic, + enabled=enabled, visible=visible, exposed=exposed) + + def getInstanceType(self): + """ + Return the correct Attribute instance corresponding to the description. + """ + # Import within the method to prevent cyclic dependencies + from meshroom.core.attribute import ShapeListAttribute + return ShapeListAttribute + +class Size2d(Shape): + """ + Size2d is a Shape attribute that allows to specify a 2d size. + Note: This attribute is not displayable. + """ + def __init__(self, name, label, description, keyable=False, keyType=None, + group="allParams", advanced=False, semantic="", + enabled=True, visible=True, exposed=False): + # Shape group desciption + groupDesc = [ + FloatParam(name="width", label="Width", description="Width size.", value=-1.0, keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), + FloatParam(name="height", label="Height", description="Height size.", value=-1.0, keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) + ] + # ShapeAttribute constructor + super(Size2d, self).__init__(groupDesc, name, label, description, group=None, advanced=advanced, semantic=semantic, + enabled=enabled, visible=visible, exposed=exposed) + +class Point2d(Shape): + """ + Point2d is a Shape attribute that allows to display and modify a 2d point. + """ + def __init__(self, name, label, description, keyable=False, keyType=None, + group="allParams", advanced=False, semantic="", + enabled=True, visible=True, exposed=False): + # Shape group desciption + groupDesc = [ + FloatParam(name="x", label="X", description="X coordinate.", value=-1.0, keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), + FloatParam(name="y", label="Y", description="Y coordinate.", value=-1.0, keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) + ] + # ShapeAttribute constructor + super(Point2d, self).__init__(groupDesc, name, label, description, group=None, advanced=advanced, semantic=semantic, + enabled=enabled, visible=visible, exposed=exposed) + +class Line2d(Shape): + """ + Line2d is a Shape attribute that allows to display and modify a 2d line. + """ + def __init__(self, name, label, description, keyable=False, keyType=None, + group="allParams", advanced=False, semantic="", + enabled=True, visible=True, exposed=False): + # Shape group desciption + groupDesc = [ + Point2d(name="a", label="A", description="Line A point.", keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), + Point2d(name="b", label="B", description="Line B point.", keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) + ] + # ShapeAttribute constructor + super(Line2d, self).__init__(groupDesc, name, label, description, group=None, advanced=advanced, semantic=semantic, + enabled=enabled, visible=visible, exposed=exposed) + +class Rectangle(Shape): + """ + Rectangle is a Shape attribute that allows to display and modify a rectangle. + """ + def __init__(self, name, label, description, keyable=False, keyType=None, + group="allParams", advanced=False, semantic="", + enabled=True, visible=True, exposed=False): + # Shape group desciption + groupDesc = [ + Point2d(name="center", label="Center", description="Rectangle center.", keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), + Size2d(name="size", label="Size", description="Rectangle size.", keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) + ] + # ShapeAttribute constructor + super(Rectangle, self).__init__(groupDesc, name, label, description, group=None, advanced=advanced, semantic=semantic, + enabled=enabled, visible=visible, exposed=exposed) + +class Circle(Shape): + """ + Circle is a Shape attribute that allows to display and modify a circle. + """ + def __init__(self, name, label, description, keyable=False, keyType=None, + group="allParams", advanced=False, semantic="", + enabled=True, visible=True, exposed=False): + # Shape group desciption + groupDesc = [ + Point2d(name="center", label="Center", description="Circle center.", keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed), + FloatParam(name="radius", label="Radius", description="Circle radius.", value=-1.0, keyable=keyable, keyType=keyType, + group=group, advanced=advanced, enabled=enabled, visible=visible, exposed=exposed) + ] + # ShapeAttribute constructor + super(Circle, self).__init__(groupDesc, name, label, description, group=None, advanced=advanced, semantic=semantic, + enabled=enabled, visible=visible, exposed=exposed) \ No newline at end of file diff --git a/meshroom/core/keyValues.py b/meshroom/core/keyValues.py index 23760e6f1d..73e78fbf36 100644 --- a/meshroom/core/keyValues.py +++ b/meshroom/core/keyValues.py @@ -80,6 +80,12 @@ def getSerializedValues(self) -> Any: Return the list of pairs serialized. """ return { str(pair.key): pair.value for pair in self._pairs } + + def getKeys(self) -> list: + """ + Return the list of keys. + """ + return [ str(pair.key) for pair in self._pairs ] def getJson(self) -> str: """ diff --git a/meshroom/core/node.py b/meshroom/core/node.py index dc12f93cc1..854827b02a 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -1749,6 +1749,16 @@ def has3DOutputAttribute(self): """ return next((attr for attr in self._attributes if attr.enabled and attr.isOutput and attr.is3dDisplayable), None) is not None + + def _hasDisplayableShape(self): + """ + Return True if at least one attribute is a ShapeAttribute, a ShapeListAttribute or a shape File. + Note: These attributes can be loaded in the the ShapeViewer / ShapeEditor. + False otherwise. + """ + return next((attr for attr in self._attributes if attr.hasDisplayableShape or + attr.desc.semantic == "shapeFile"), None) is not None + name = Property(str, getName, constant=True) defaultLabel = Property(str, getDefaultLabel, constant=True) @@ -1802,6 +1812,8 @@ def has3DOutputAttribute(self): hasImageOutput = Property(bool, hasImageOutputAttribute, notify=outputAttrEnabledChanged) hasSequenceOutput = Property(bool, hasSequenceOutputAttribute, notify=outputAttrEnabledChanged) has3DOutput = Property(bool, has3DOutputAttribute, notify=outputAttrEnabledChanged) + # Whether the node contains a ShapeAttribute, a ShapeListAttribute or a shape File. + hasDisplayableShape = Property(bool, _hasDisplayableShape, constant=True) class Node(BaseNode): diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index 570d36bfa3..fb50f9f958 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -27,6 +27,7 @@ from meshroom.ui.components.scriptEditor import ScriptEditorManager from meshroom.ui.components.thumbnail import ThumbnailCache from meshroom.ui.components.messaging import MessageController +from meshroom.ui.components.shapes import ShapeFilesHelper, ShapeViewerHelper from meshroom.ui.palette import PaletteManager from meshroom.ui.reconstruction import Reconstruction from meshroom.ui.utils import QmlInstantEngine @@ -282,6 +283,8 @@ def __init__(self, inputArgs): self.engine.rootContext().setContextProperty("Transformations3DHelper", Transformations3DHelper(parent=self)) self.engine.rootContext().setContextProperty("Clipboard", ClipboardHelper(parent=self)) self.engine.rootContext().setContextProperty("ThumbnailCache", ThumbnailCache(parent=self)) + self.engine.rootContext().setContextProperty("ShapeFilesHelper", ShapeFilesHelper(self.activeProject, parent=self)) + self.engine.rootContext().setContextProperty("ShapeViewerHelper", ShapeViewerHelper(parent=self)) # additional context properties self._messageController = MessageController(parent=self) diff --git a/meshroom/ui/commands.py b/meshroom/ui/commands.py index 1e55200dd1..2fdfebf6fa 100755 --- a/meshroom/ui/commands.py +++ b/meshroom/ui/commands.py @@ -370,7 +370,58 @@ def undoImpl(self): else: self.graph.internalAttribute(self.attrName).keyValues.add(self.key, self.oldValue) return True - + +class SetObservationCommand(GraphCommand): + def __init__(self, graph, attribute, key, observation, parent=None): + super().__init__(graph, parent) + self.attrName = attribute.fullName + self.key = key + self.observation = observation.toVariant() + self.oldObservation = attribute.getObservation(key) + self.setText(f"Set observation for shape attribute '{attribute.fullName}' at key: '{key}'") + + def redoImpl(self): + if self.graph.attribute(self.attrName) is not None: + self.graph.attribute(self.attrName).setObservation(self.key, self.observation) + else: + self.graph.internalAttribute(self.attrName).setObservation(self.key, self.observation) + return True + + def undoImpl(self): + if self.graph.attribute(self.attrName) is not None: + if self.oldObservation is None: + self.graph.attribute(self.attrName).removeObservation(self.key) + else: + self.graph.attribute(self.attrName).setObservation(self.key, self.oldObservation) + else: + if self.oldObservation is None: + self.graph.internalAttribute(self.attrName).removeObservation(self.key) + else: + self.graph.internalAttribute(self.attrName).setObservation(self.key, self.oldObservation) + return True + +class RemoveObservationCommand(GraphCommand): + def __init__(self, graph, attribute, key, parent=None): + super().__init__(graph, parent) + self.attrName = attribute.fullName + self.key = key + self.oldObservation = attribute.getObservation(key) + self.setText(f"Remove observation for shape attribute '{attribute.fullName}' at key: '{key}'") + + def redoImpl(self): + if self.graph.attribute(self.attrName) is not None: + self.graph.attribute(self.attrName).removeObservation(self.key) + else: + self.graph.internalAttribute(self.attrName).removeObservation(self.key) + return True + + def undoImpl(self): + if self.graph.attribute(self.attrName) is not None: + self.graph.attribute(self.attrName).setObservation(self.key, self.oldObservation) + else: + self.graph.internalAttribute(self.attrName).setObservation(self.key, self.oldObservation) + return True + class AddEdgeCommand(GraphCommand): def __init__(self, graph, src, dst, parent=None): super().__init__(graph, parent) diff --git a/meshroom/ui/components/shapes/__init__.py b/meshroom/ui/components/shapes/__init__.py new file mode 100644 index 0000000000..b594082c43 --- /dev/null +++ b/meshroom/ui/components/shapes/__init__.py @@ -0,0 +1,6 @@ +from .shapeFilesHelper import ( + ShapeFilesHelper +) +from .shapeViewerHelper import ( + ShapeViewerHelper +) \ No newline at end of file diff --git a/meshroom/ui/components/shapes/shapeFile.py b/meshroom/ui/components/shapes/shapeFile.py new file mode 100644 index 0000000000..2895c214ac --- /dev/null +++ b/meshroom/ui/components/shapes/shapeFile.py @@ -0,0 +1,214 @@ +from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, Slot +from meshroom.core.attribute import Attribute +import json, os, re + +class ShapeFile(BaseObject): + """ + List of shapes provided by a json file attribute. + """ + + class ShapeData(BaseObject): + """ + Single shape with its properties and observations. + """ + def __init__(self, name: str, type: str, properties={}, observations={}, parent=None): + super().__init__(parent) + # View id + self._viewId = "-1" + # Shape name + self._name = name + # Shape type (Point2d, Line2d, Rectangle, Circle, etc.) + self._type = type + # Shape properties (color, stroke, etc.) + self._properties = properties + # Shape observations {viewId: observation{x, y, radius, etc.}} + self._observations = observations + # Shape keyabale + self._keyable = len(observations) > 0 + # Shape visible + self._visible = True + + def _getVisible(self) -> bool: + """ + Return whether the shape is visible for display. + """ + return self._visible + + def _setVisible(self, visible:bool): + """ + Set the shape visibility for display. + """ + self._visible = visible + self.visibleChanged.emit() + + def setViewId(self, viewId: str): + """ + Set the shape current view id. + """ + self._viewId = viewId + self.viewIdChanged.emit() + + def _getObservation(self): + """ + Get the shape current observation. + """ + if self._keyable: + return self._observations.get(self._viewId, None) + return self._properties + + def _getNbObservations(self): + """ + Return the shape number of observations. + """ + if self._keyable: + return len(self._observations) + return 1 + + @Slot(str, result=bool) + def hasObservation(self, key: str) -> bool: + """ + Return whether the shape has an observation for the given key. + """ + if self._keyable: + return self._observations.get(self._viewId, None) is not None + return True + + # Signals + viewIdChanged = Signal() + visibleChanged = Signal() + + # Properties + # The shape name. + name = Property(str, lambda self: self._name, constant=True) + # The shape label. + label = Property(str, lambda self: self._name, constant=True) + # The shape type (Point2d, Line2d, Rectangle, Circle, etc.). + type = Property(str, lambda self: self._type, constant=True) + # Whether the shape is keyabale (multiple observations). + shapeKeyable = Property(bool,lambda self: self._keyable, constant=True) + # The shape properties (color, stroke, etc.). + properties = Property(Variant, lambda self: self._properties, constant=True) + # The shape current observation. + observation = Property(Variant, _getObservation, notify=viewIdChanged) + # The shape list of observation keys. + observationKeys = Property(Variant, lambda self: [key for key in self._observations], constant=True) + # The number of observation defined. + nbObservations = Property(int, _getNbObservations, constant=True) + # Whether the shape is displayable. + isVisible = Property(bool, _getVisible, _setVisible, notify=visibleChanged) + + def __init__(self, fileAttribute: Attribute, viewId: str, parent=None): + super().__init__(parent) + # List of shapes + self._shapes = ListModel(parent=self) + # File attribute + self._fileAttribute = fileAttribute + # Current view id + self._viewId = viewId + # Shapes visible + self._visible = True + # Populate the model from the provided file + self._loadShapesFromJsonFile() + # Update viewId for all shapes + self.setViewId(viewId) + # Connect file attribute value + fileAttribute.valueChanged.connect(self._loadShapesFromJsonFile) + + def _getVisible(self) -> bool: + """ + Return whether the shape file is visible for display. + """ + return self._visible + + def _setVisible(self, visible:bool): + """ + Set the shape file visibility for display. + """ + self._visible = visible + for shape in self._shapes: + shape.isVisible = visible + self.visibleChanged.emit() + + def _getBasename(self) -> str: + """ + Get file attribute basename. + """ + return os.path.basename(self._fileAttribute.value) + + def setViewId(self, viewId: str): + """ + Set the current view id for all shapes of the file. + """ + for shape in self._shapes: + shape.setViewId(viewId) + + @Slot() + def _loadShapesFromJsonFile(self): + """ + Load shapes from the json file. + """ + def convertNumericStrings(obj): + """ + Helper function to convert numeric strings. + """ + if isinstance(obj, dict): + return {k: convertNumericStrings(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [convertNumericStrings(elem) for elem in obj] + elif isinstance(obj, str): + # Check for int or float + if re.fullmatch(r'-?\d+', obj): + return int(obj) + elif re.fullmatch(r'-?\d+\.\d*', obj): + return float(obj) + return obj + + # Clear model + self._shapes.clear() + # Load from json file + if os.path.exists(self._fileAttribute.value): + try: + with open(self._fileAttribute.value, "r") as f: + # Load json + loadedData = json.load(f) + # Handle both formats: direct array or object with "shapes" key + if isinstance(loadedData, dict) and "shapes" in loadedData: + shapesArray = loadedData["shapes"] + elif isinstance(loadedData, list): + shapesArray = loadedData + else: + print("Invalid JSON format: expected array or object with 'shapes' key") + self.fileChanged.emit() + return + # Build shapes from proper shapes array + for itemData in convertNumericStrings(shapesArray): + name = itemData.get("name", "unknown") + type = itemData.get("type", "unknown") + properties = itemData.get("properties", {}) + observations = itemData.get("observations", {}) + self._shapes.append(ShapeFile.ShapeData(name, type, properties, observations, self._shapes)) + except FileNotFoundError: + print("No shapes found to load.") + except json.JSONDecodeError as e: + print(f"Error decoding JSON: {e}") + except Exception as e: + print(f"Error loading shapes: {e}") + self.fileChanged.emit() + + # Signals + fileChanged = Signal() + visibleChanged = Signal() + + # Properties + # The model type, always ShapeFile. + type = Property(str, lambda self: "ShapeFile", constant=True) + # The corresponding File attribute label. + label = Property(str, lambda self: self._fileAttribute.label, constant=True) + # The file basename. + basename = Property(str, _getBasename, notify=fileChanged) + # The list of shapes. + shapes = Property(Variant, lambda self: self._shapes, notify=fileChanged) + # Whether the file has shapes. + isEmpty = Property(bool, lambda self: len(self._shapes) <= 0, notify=fileChanged) + # Whether the file is displayable. + isVisible = Property(bool, _getVisible, _setVisible, notify=visibleChanged) \ No newline at end of file diff --git a/meshroom/ui/components/shapes/shapeFilesHelper.py b/meshroom/ui/components/shapes/shapeFilesHelper.py new file mode 100644 index 0000000000..e93c94a453 --- /dev/null +++ b/meshroom/ui/components/shapes/shapeFilesHelper.py @@ -0,0 +1,54 @@ +from meshroom.ui.reconstruction import Reconstruction +from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, Slot +from meshroom.core.attribute import GroupAttribute, ListAttribute +from .shapeFile import ShapeFile + +class ShapeFilesHelper(BaseObject): + """ + Manages active project selected node shape files. + """ + + def __init__(self, activeProject:Reconstruction, parent=None): + super().__init__(parent) + self._activeProject = activeProject + self._shapeFiles = ListModel() + self._activeProject.selectedViewIdChanged.connect(self._onSelectedViewIdChanged) + self._activeProject.selectedNodeChanged.connect(self._onSelectedNodeChanged) + + def _loadShapeFilesFromAttributes(self, attributes): + """ + Search for File attribute with shape file semantic in selected node attributes. + Update the model based on the shape files found. + """ + for attribute in attributes: + if isinstance(attribute, (ListAttribute, GroupAttribute)): + self._loadShapeFilesFromAttributes(attribute.value) + elif attribute.type == "File" and attribute.desc.semantic == "shapeFile": + self._shapeFiles.append(ShapeFile(fileAttribute=attribute, + viewId=self._activeProject.selectedViewId, + parent=self._shapeFiles)) + + @Slot() + def _onSelectedViewIdChanged(self): + """Callback when the active project selected view id changes.""" + for shapeFile in self._shapeFiles: + shapeFile.setViewId(self._activeProject.selectedViewId) + + @Slot() + def _onSelectedNodeChanged(self): + """Callback when the active project selected node changes.""" + # clear shapeFiles model + self._shapeFiles = ListModel() + # check current node + if self._activeProject.selectedNode is None: + return + # check current node has displayable shape + if not self._activeProject.selectedNode.hasDisplayableShape: + return + # load node shape files + self._loadShapeFilesFromAttributes(self._activeProject.selectedNode.attributes) + self.nodeShapeFilesChanged.emit() + + # Properties and signals + nodeShapeFilesChanged = Signal() + nodeShapeFiles = Property(Variant, lambda self: self._shapeFiles, notify=nodeShapeFilesChanged) \ No newline at end of file diff --git a/meshroom/ui/components/shapes/shapeViewerHelper.py b/meshroom/ui/components/shapes/shapeViewerHelper.py new file mode 100644 index 0000000000..956ef26993 --- /dev/null +++ b/meshroom/ui/components/shapes/shapeViewerHelper.py @@ -0,0 +1,75 @@ +from meshroom.common import BaseObject, Property, Variant, Signal, Slot + +class ShapeViewerHelper(BaseObject): + """ + Manages interactions with the qml ShapeViewer (2d Viewer). + - Handle shape selection. + - Handle shape observation initialization. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._selectedShapeName = "" + self._containerWidth = 0.0 + self._containerHeight = 0.0 + self._containerScale = 0.0 + + def _getSelectedShapeName(self) -> str: + return self._selectedShapeName + + def _getContainerWidth(self) -> float: + return self._containerWidth + + def _getContainerHeight(self) -> float: + return self._containerHeight + + def _getContainerScale(self) -> float: + return self._containerScale + + def _setSelectedShapeName(self, shapeName:str): + self._selectedShapeName = shapeName + self.selectedShapeNameChanged.emit() + + def _setContainerWidth(self, width: float): + self._containerWidth = width + self.containerWidthChanged.emit() + + def _setContainerHeight(self, height: float): + self._containerHeight= height + self.containerHeightChanged.emit() + + def _setContainerScale(self, scale: float): + self._containerScale = scale + self.containerScaleChanged.emit() + + @Slot(str, result=Variant) + def getDefaultObservation(self, shapeType: str) -> Variant: + """ + Helper function to create a shape default observation. + """ + match shapeType: + case "Point2d": + return { "x": self._containerWidth * 0.5, "y": self._containerHeight * 0.5} + case "Line2d": + return { "a": { "x": self._containerWidth * 0.4, "y": self._containerHeight * 0.4}, + "b": { "x": self._containerWidth * 0.6, "y": self._containerHeight * 0.6}} + case "Circle": + return { "center": {"x": self._containerWidth * 0.5, "y": self._containerHeight * 0.5}, + "radius": self._containerWidth * 0.1} + case "Rectangle": + return { "center": { "x": self._containerWidth * 0.5, "y": self._containerHeight * 0.5}, + "size": { "width": self._containerWidth * 0.2, "height": self._containerHeight * 0.2}} + return None + + # Properties and signals + selectedShapeNameChanged = Signal() + selectedShapeName = Property(str, _getSelectedShapeName, _setSelectedShapeName, notify=selectedShapeNameChanged) + + containerWidthChanged = Signal() + containerWidth = Property(float, _getContainerWidth, _setContainerWidth, notify=containerWidthChanged) + + containerHeightChanged = Signal() + containerHeight = Property(float, _getContainerHeight, _setContainerHeight, notify=containerHeightChanged) + + containerScaleChanged = Signal() + containerScale = Property(float, _getContainerScale, _setContainerScale, notify=containerScaleChanged) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 59534a36ba..5a2bbb86af 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -23,7 +23,7 @@ from meshroom.core import sessionUid from meshroom.common.qt import QObjectListModel -from meshroom.core.attribute import Attribute, ListAttribute +from meshroom.core.attribute import Attribute, ListAttribute, ShapeAttribute from meshroom.core.graph import Graph, Edge, generateTempProjectFilepath from meshroom.core.graphIO import GraphIO @@ -978,6 +978,24 @@ def removeAttributeKey(self, attribute, key): """ Remove the given key from the given keyable attribute. """ self.push(commands.RemoveAttributeKeyCommand(self._graph, attribute, key)) + @Slot(str, str, "QVariant") + def setObservationFromName(self, shapeFullName, key, observation): + """ Set the given observation for the given shape attribute name. """ + shape = self.graph.attribute(shapeFullName) + if shape is None: + shape = self.graph.internalAttribute(shapeFullName) + self.push(commands.SetObservationCommand(self._graph, shape, key, observation)) + + @Slot(ShapeAttribute, str, "QVariant") + def setObservation(self, shape, key, observation): + """ Set the given observation for the given shape attribute. """ + self.push(commands.SetObservationCommand(self._graph, shape, key, observation)) + + @Slot(ShapeAttribute, str) + def removeObservation(self, shape, key): + """ Remove the given observation for the given shape attribute. """ + self.push(commands.RemoveObservationCommand(self._graph, shape, key)) + @Slot(CompatibilityNode, result=Node) def upgradeNode(self, node): """ Upgrade a CompatibilityNode. """ diff --git a/meshroom/ui/qml/GraphEditor/AttributeEditor.qml b/meshroom/ui/qml/GraphEditor/AttributeEditor.qml index f2006a41bd..9c8dddbab1 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeEditor.qml @@ -27,7 +27,7 @@ ListView { ScrollBar.vertical: MScrollBar { id: scrollBar } delegate: Loader { - active: (object.enabled || object.hasAnyOutputLinks) && ( + active: !object.hasDisplayableShape && (object.enabled || object.hasAnyOutputLinks) && ( !objectsHideable || ((!object.desc.advanced || GraphEditorSettings.showAdvancedAttributes) && (object.isDefault && GraphEditorSettings.showDefaultAttributes || !object.isDefault && GraphEditorSettings.showModifiedAttributes) diff --git a/meshroom/ui/qml/GraphEditor/NodeEditor.qml b/meshroom/ui/qml/GraphEditor/NodeEditor.qml index 7a43db7f6a..db824eebdb 100644 --- a/meshroom/ui/qml/GraphEditor/NodeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/NodeEditor.qml @@ -5,6 +5,7 @@ import QtQuick.Layouts import Controls 1.0 import MaterialIcons 2.2 import Utils 1.0 +import Shapes 1.0 /** * NodeEditor allows to visualize and edit the parameters of a Node. @@ -294,23 +295,44 @@ Panel { currentIndex: tabBar.currentIndex - AttributeEditor { - id: inOutAttr - objectsHideable: true - Layout.fillHeight: true - Layout.fillWidth: true - model: root.node.attributes - readOnly: root.readOnly || root.isCompatibilityNode - onAttributeDoubleClicked: function(mouse, attribute) { root.attributeDoubleClicked(mouse, attribute) } - onUpgradeRequest: root.upgradeRequest() - onShowInViewer: function (attribute) {root.showAttributeInViewer(attribute)} - filterText: searchBar.text - - onInAttributeClicked: function(srcItem, mouse, inAttributes) { - root.inAttributeClicked(srcItem, mouse, inAttributes) + // First tab + MSplitView { + orientation: Qt.Vertical + + // Node shape editor + Loader { + id: shapeEditorLoader + active: _reconstruction ? + (_reconstruction.selectedNode ? _reconstruction.selectedNode.hasDisplayableShape : false) : false + sourceComponent: ShapeEditor { + model: root.node.attributes + filterText: searchBar.text + } + SplitView.preferredHeight: active ? 200 : 0 + SplitView.minimumHeight: active ? 100 : 0 + SplitView.maximumHeight: active ? 400 : 0 } - onOutAttributeClicked: function(srcItem, mouse, outAttributes) { - root.outAttributeClicked(srcItem, mouse, outAttributes) + + // Node attribute editor + AttributeEditor { + id: inOutAttr + objectsHideable: true + Layout.fillHeight: true + Layout.fillWidth: true + SplitView.minimumHeight: 100 + model: root.node.attributes + readOnly: root.readOnly || root.isCompatibilityNode + onAttributeDoubleClicked: function(mouse, attribute) { root.attributeDoubleClicked(mouse, attribute) } + onUpgradeRequest: root.upgradeRequest() + onShowInViewer: function (attribute) {root.showAttributeInViewer(attribute)} + filterText: searchBar.text + + onInAttributeClicked: function(srcItem, mouse, inAttributes) { + root.inAttributeClicked(srcItem, mouse, inAttributes) + } + onOutAttributeClicked: function(srcItem, mouse, outAttributes) { + root.outAttributeClicked(srcItem, mouse, outAttributes) + } } } diff --git a/meshroom/ui/qml/Shapes/Editor/Items/ShapeAttributeItem.qml b/meshroom/ui/qml/Shapes/Editor/Items/ShapeAttributeItem.qml new file mode 100644 index 0000000000..6605ee59e8 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Editor/Items/ShapeAttributeItem.qml @@ -0,0 +1,64 @@ +import QtQuick +import QtQuick.Controls + +import "Utils" as ItemUtils + +/** +* ShapeAttributeItem +* +* @biref ShapeAttribute component for the ShapeEditor. +* @param shapeAttribute - the given ShapeAttribute model +* @param isNeasted - whether the item is neasted +*/ +Column { + id: shapeAttributeItem + width: parent.width + spacing: 0 + + // Properties + property var shapeAttribute + property alias isNeasted: itemHeader.isNeasted + property alias isLinkChild: itemHeader.isLinkChild + + + function hasCurrentObservation() { + return shapeAttribute ? shapeAttribute.hasObservation(_reconstruction ? _reconstruction.selectedViewId : "-1") : false + } + + // Reload hasObservation property + // When shape attribute observations changed (signal) + Connections { + target: shapeAttribute + function onObservationsChanged() { itemHeader.hasShapeObservation = hasCurrentObservation() } + } + // When reconstruction view id changed (signal) + Connections { + target: _reconstruction + function onSelectedViewIdChanged() { itemHeader.hasShapeObservation = hasCurrentObservation() } + } + + // Item Header + ItemUtils.ItemHeader { + id: itemHeader + model: shapeAttribute + hasShapeObservation: hasCurrentObservation() + isShape: true + isAttribute: true + } + + // Expandable list + Loader { + active: itemHeader.isExpanded + width: parent.width + height: active ? (item ? item.implicitHeight || item.height : 0) : 0 + + sourceComponent: Pane { + background: Rectangle { color: "transparent" } + padding: 0 + implicitWidth: parent.width + implicitHeight: 20 + + //Shape attribute observation + } + } +} diff --git a/meshroom/ui/qml/Shapes/Editor/Items/ShapeDataItem.qml b/meshroom/ui/qml/Shapes/Editor/Items/ShapeDataItem.qml new file mode 100644 index 0000000000..0db56ecc3e --- /dev/null +++ b/meshroom/ui/qml/Shapes/Editor/Items/ShapeDataItem.qml @@ -0,0 +1,46 @@ +import QtQuick +import QtQuick.Controls + +import "Utils" as ItemUtils + +/** +* ShapeDataItem +* +* @biref ShapeData component for the ShapeEditor. +* @param shapeData - the given ShapeData model +* @param isNeasted - whether the item is neasted +*/ +Column { + id: shapeDataItem + width: parent.width + spacing: 0 + + // Properties + property var shapeData + property alias isNeasted: itemHeader.isNeasted + + // Item Header + ItemUtils.ItemHeader { + id: itemHeader + model: shapeData + hasShapeObservation: shapeData.hasObservation(_reconstruction.selectedViewId) + isShape: true + isAttribute: false + } + + // Expandable list + Loader { + active: itemHeader.isExpanded + width: parent.width + height: active ? (item ? item.implicitHeight || item.height : 0) : 0 + + sourceComponent: Pane { + background: Rectangle { color: "transparent" } + padding: 0 + implicitWidth: parent.width + implicitHeight: 20 + + //Shape data observation + } + } +} diff --git a/meshroom/ui/qml/Shapes/Editor/Items/ShapeFileItem.qml b/meshroom/ui/qml/Shapes/Editor/Items/ShapeFileItem.qml new file mode 100644 index 0000000000..0e4b4e7f2b --- /dev/null +++ b/meshroom/ui/qml/Shapes/Editor/Items/ShapeFileItem.qml @@ -0,0 +1,55 @@ +import QtQuick +import QtQuick.Controls + +import "Utils" as ItemUtils + +/** +* ShapeFileItem +* +* @biref ShapeFile component for the ShapeEditor. +* @param shapeFile - the given ShapeFile model +*/ +Column { + id: shapeFileItem + width: parent.width + spacing: 0 + + // Properties + property var shapeFile + + // Item Header + ItemUtils.ItemHeader { + id: itemHeader + model: shapeFile + isShape: false + isAttribute: false + } + + // Expandable list + Loader { + active: itemHeader.isExpanded + width: parent.width + height: active ? (item ? item.implicitHeight || item.height : 0) : 0 + + sourceComponent: Pane { + background: Rectangle { color: "transparent" } + padding: 0 + implicitWidth: parent.width + implicitHeight: subList.contentHeight + + ListView { + id: subList + anchors.fill: parent + spacing: 2 + interactive: false + model: shapeFile.shapes + delegate: ShapeDataItem { + shapeData: object + isNeasted: true + width: ListView.view.width + height: implicitHeight + } + } + } + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Shapes/Editor/Items/ShapeListAttributeItem.qml b/meshroom/ui/qml/Shapes/Editor/Items/ShapeListAttributeItem.qml new file mode 100644 index 0000000000..fb5c220ad6 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Editor/Items/ShapeListAttributeItem.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtQuick.Controls + +import "Utils" as ItemUtils + +/** +* ShapeListAttributeItem +* +* @biref ShapeListAttribute component for the ShapeEditor. +* @param shapeListAttribute - the given ShapeListAttribute model +*/ +Column { + id: shapeListAttributeItem + width: parent.width + spacing: 0 + + // Properties + property var shapeListAttribute + + // Item Header + ItemUtils.ItemHeader { + id: itemHeader + model: shapeListAttribute + isShape: false + isAttribute: true + } + + // Expandable list + Loader { + active: itemHeader.isExpanded + width: parent.width + height: active ? (item ? item.implicitHeight || item.height : 0) : 0 + + sourceComponent: Pane { + background: Rectangle { color: "transparent" } + padding: 0 + implicitWidth: parent.width + implicitHeight: subList.contentHeight + + ListView { + id: subList + anchors.fill: parent + spacing: 2 + interactive: false + model: shapeListAttribute.value + delegate: ShapeAttributeItem { + shapeAttribute: object + isNeasted: true + isLinkChild: shapeListAttribute.isLink + width: ListView.view.width + height: implicitHeight + } + } + } + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Shapes/Editor/Items/Utils/ItemHeader.qml b/meshroom/ui/qml/Shapes/Editor/Items/Utils/ItemHeader.qml new file mode 100644 index 0000000000..af39bd6034 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Editor/Items/Utils/ItemHeader.qml @@ -0,0 +1,451 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import MaterialIcons 2.2 +import Controls 1.0 +import Utils 1.0 + +/** +* ItemHeader +* +* @biref Item header component for the ShapeEditor. +* @param model - the given model (provide by the current node or ShapeFilesHelper) +* @param isShape - whether the model is a shape (ShapeAttribute or ShapeData) +* @param isAttribute - whether the model is an attribute (ShapeAttribute or ShapeListAttribute) +* @param hasShapeObservation - whether the model is a shape with a current observation +* @param isNeasted - whether the header is neasted +* @param isLinkChild - Whether the model is a child attribute of a linked attribute +* @param isExpanded - whether the heder is expanded +*/ +Pane { + id: itemHeader + width: parent.width + + // Model properties + property var model + property bool isShape: false + property bool isAttribute: false + property bool isLinkChild: false + property bool hasShapeObservation: false + + // Header properties + property bool isNeasted: false + property bool isExpanded: false + + // Read-only properties + readonly property bool isAttributeSelected: isAttribute ? (ShapeViewerHelper.selectedShapeName === model.fullName) : false + readonly property bool isAttributeInitialized: isAttribute ? !model.isDefault : false + readonly property bool isAttributeEnabled: isAttribute ? (model.enabled && !model.isLink && !isLinkChild) : false + + // Padding + topPadding: 2 + bottomPadding: 2 + rightPadding: 5 + leftPadding: 5 + + // Background + background: Rectangle { + radius: 3 + border.color: palette.highlight + border.width: { + if(isAttributeSelected) + return 2 + return 0 + } + color: { + if(isAttributeSelected) + return palette.window + if(hoverHandler.hovered) + return Qt.darker(palette.window, 1.1) + return "transparent" + } + + SequentialAnimation { + id: flickAnimation + loops: 2 + + NumberAnimation { + target: itemHeader.background + property: "border.width" + to: 1 + duration: 100 + } + NumberAnimation { + target: itemHeader.background + property: "border.width" + to: 0 + duration: 100 + } + PauseAnimation { duration: 50 } + } + } + + // Item header menu + // Popup on right mouse button + Menu { + id: itemHeaderMenu + MenuItem { + text: "Reset" + enabled: isAttributeEnabled && isAttributeInitialized + onTriggered: { + _reconstruction.resetAttribute(model) + ShapeViewerHelper.selectedShapeName = "" + isExpanded = false + } + } + } + + // Hover Handle + HoverHandler { + id: hoverHandler + margin: 3 + } + + // Tap Handler + // Left and Right mouse button handler + TapHandler { + acceptedButtons: Qt.LeftButton | Qt.RightButton + gesturePolicy: TapHandler.WithinBounds + margin: 3 + onTapped: function(eventPoint, button) { + // Right mouse button + if(button === Qt.RightButton) + itemHeaderMenu.popup() + + // Left mouse button + if(button === Qt.LeftButton && isShape && isAttributeEnabled && isAttributeInitialized) + { + // Single tap + if(tapCount === 1 && model.isVisible) + { + ShapeViewerHelper.selectedShapeName = model.fullName + } + + // Double tap + if(tapCount === 2 && !model.isVisible) + { + + ShapeViewerHelper.selectedShapeName = model.fullName + model.isVisible = true + } + } + else + { + flickAnimation.start() + } + } + } + + // MaterialIcons font metrics + FontMetrics { + id: materialMetrics + font.family: MaterialIcons.fontFamily + font.pointSize: 11 + } + + // Row layout + RowLayout { + anchors.fill: parent + anchors.rightMargin: 2 + spacing: 0 + + // Shape visibility + MaterialToolButton { + font.pointSize: 9 + padding: (materialMetrics.height / 11.0) + 2 + text: model.isVisible ? MaterialIcons.visibility : MaterialIcons.visibility_off + opacity: model.isVisible ? 1.0 : 0.5 + enabled: true + onClicked: { model.isVisible = !model.isVisible } + ToolTip.text: model.isVisible ? "Visible" : "Hidden" + ToolTip.visible: hovered + ToolTip.delay: 800 + } + + // Neasted spacer + // 1x icon + 2x padding + Item { + visible: isNeasted + width: materialMetrics.height + 4 + } + + // Shape attributes dropdown + MaterialToolButton { + font.pointSize: 11 + padding: 2 + text: { + if(isExpanded) { + return (isShape) ? MaterialIcons.arrow_drop_down : MaterialIcons.keyboard_arrow_down + } + else { + return (isShape) ? MaterialIcons.arrow_right : MaterialIcons.keyboard_arrow_right + } + } + onClicked: { isExpanded = !isExpanded } + enabled: true + ToolTip.text: isExpanded ? "Collapse" : "Expand" + ToolTip.visible: hovered + ToolTip.delay: 800 + } + + // Shape color + Loader { + active: isShape + sourceComponent: ToolButton { + enabled: isAttributeEnabled + contentItem: Rectangle { + anchors.centerIn: parent + color: isAttribute ? model.shapeColor : model.properties.color || "black" + width: materialMetrics.height + height: materialMetrics.height + } + onClicked: shapeColorDialog.item.open() + ToolTip.text: "Shape Color" + ToolTip.visible: hovered + ToolTip.delay: 800 + } + } + + // Shape ColorDialog + Loader { + id: shapeColorDialog + active: isShape && isAttributeEnabled + sourceComponent: ColorDialog { + title: "Edit " + model.label + " color" + selectedColor: model.shapeColor + onAccepted: { + model.shapeColor = selectedColor + close() + } + onRejected: close() + } + } + + // Shape type and shape name + RowLayout { + spacing: 2 + opacity: (isAttributeEnabled && isAttributeInitialized) ? 1.0 : 0.7 + + // Shape type + MaterialLabel { + font.pointSize: 11 + padding: 2 + text: { + switch(model.type) { + case "ShapeFile": return MaterialIcons.insert_drive_file; + case "ShapeList": return MaterialIcons.layers; + case "Point2d": return MaterialIcons.control_camera; + case "Line2d": return MaterialIcons.linear_scale; + case "Circle": return MaterialIcons.radio_button_unchecked; + case "Rectangle": return MaterialIcons.crop_landscape; + case "Text": return MaterialIcons.title; + default: return MaterialIcons.question_mark; + } + } + } + + // Shape name + Label { + text: model.label + font.pointSize: 8 + } + + // Shape index + Loader { + active: isAttribute && model.root && (model.root.type === "ShapeList") + sourceComponent: Label { + text: "[" + index + "]" + font.pointSize: 8 + } + } + + // Shape file basename + Loader { + active: !isShape && !isAttribute && model.basename !== "" + sourceComponent: Label { + font.pointSize: 8 + text: "(" + model.basename + ")" + } + } + + // Shape number of observations + Loader { + active: isShape && model.shapeKeyable + sourceComponent: Label { + text: "(" + model.nbObservations + ")" + font.pointSize: 8 + } + } + } + + // Spacer + Item { Layout.fillWidth: true } + + // Right toolbar + RowLayout { + spacing: 0 + + // Shape not keyable, set/remove observation + Loader { + active: isShape && isAttribute && !model.shapeKeyable + sourceComponent: MaterialToolButton { + font.pointSize: 11 + padding: 2 + text: isAttributeInitialized ? MaterialIcons.clear : MaterialIcons.edit + checkable: false + enabled: isAttributeEnabled + onClicked: { + if(isAttributeInitialized) + { + // remove key + _reconstruction.removeObservation(model, _reconstruction.selectedViewId) + ShapeViewerHelper.selectedShapeName = "" + } + else + { + // add key + _reconstruction.setObservation(model, _reconstruction.selectedViewId, ShapeViewerHelper.getDefaultObservation(model.type)) + ShapeViewerHelper.selectedShapeName = model.fullName + } + } + ToolTip.text: isAttributeInitialized ? "Reset Shape" : "Set Shape" + ToolTip.visible: hovered + ToolTip.delay: 800 + } + } + + // Shape keyable, set/remove observation + Loader { + active: isShape && model.shapeKeyable + sourceComponent: RowLayout { + spacing: 0 + + function getViewPath(viewId) { + for (var i = 0; i < _reconstruction.viewpoints.count; i++) + { + var vp = _reconstruction.viewpoints.at(i) + if (vp.childAttribute("viewId").value == viewId) + return vp.childAttribute("path").value + } + return undefined + } + + function getPrevViewId(viewIds, currentViewId) { + const currentViewPath = getViewPath(currentViewId) + const prevIds = viewIds.filter(viewId => getViewPath(viewId) < currentViewPath) + if (prevIds.length === 0) + return "-1"; + prevIds.sort((a, b) => getViewPath(b).localeCompare(getViewPath(a))) + return prevIds[0] + } + + function getNextViewId(viewIds, currentViewId) { + const currentViewPath = getViewPath(currentViewId) + const nextIds = viewIds.filter(viewId => getViewPath(viewId) > currentViewPath) + if (nextIds.length === 0) + return "-1"; + nextIds.sort((a, b) => getViewPath(a).localeCompare(getViewPath(b))) + return nextIds[0] + } + + // Previous key + MaterialToolButton { + property string prevViewId: getPrevViewId(model.observationKeys, _reconstruction.selectedViewId) + font.pointSize: 11 + padding: 2 + text: MaterialIcons.keyboard_arrow_left + checkable: false + enabled: prevViewId !== "-1" + onClicked: { _reconstruction.selectedViewId = prevViewId } + ToolTip.text: enabled ? "Previous Key" : "No Previous Key" + ToolTip.visible: hovered + ToolTip.delay: 800 + } + + // Current key + MaterialToolButton { + font.pointSize: 11 + padding: 2 + text: MaterialIcons.noise_control_off + checkable: model.shapeKeyable + checked: model.shapeKeyable ? hasShapeObservation : false + enabled: isAttributeEnabled + onClicked: { + if(hasShapeObservation) + { + // remove key + _reconstruction.removeObservation(model, _reconstruction.selectedViewId) + ShapeViewerHelper.selectedShapeName = "" + } + else + { + // add key + _reconstruction.setObservation(model, _reconstruction.selectedViewId, ShapeViewerHelper.getDefaultObservation(model.type)) + ShapeViewerHelper.selectedShapeName = model.fullName + } + } + ToolTip.text: hasShapeObservation ? "Remove current key" : "Set current key" + ToolTip.visible: hovered + ToolTip.delay: 800 + } + + // Next key + MaterialToolButton { + property string nextViewId: getNextViewId(model.observationKeys, _reconstruction.selectedViewId) + font.pointSize: 11 + padding: 2 + text: MaterialIcons.keyboard_arrow_right + checkable: false + enabled: nextViewId !== "-1" + onClicked: { _reconstruction.selectedViewId = nextViewId } + ToolTip.text: enabled ? "Next Key" : "No Next Key" + ToolTip.visible: hovered + ToolTip.delay: 800 + } + } + } + + // Shape list add element + Loader { + active: !isShape && isAttributeEnabled + sourceComponent: MaterialToolButton { + font.pointSize: 11 + padding: 2 + text: MaterialIcons.control_point + onClicked: _reconstruction.appendAttribute(model, undefined) + ToolTip.text: "Add Element" + ToolTip.visible: hovered + ToolTip.delay: 800 + } + } + + // Shape list delete element + Loader { + active: isAttributeEnabled && model.root && (model.root.type === "ShapeList") + sourceComponent: MaterialToolButton { + font.pointSize: 11 + padding: 2 + text: MaterialIcons.remove_circle_outline + onClicked: { + _reconstruction.removeAttribute(model) + } + ToolTip.text: "Remove Element" + ToolTip.visible: hovered + ToolTip.delay: 800 + } + } + + // Shape is a link or locked + Loader { + active: !isAttributeEnabled + sourceComponent: MaterialLabel { + font.pointSize: 11 + padding: 2 + opacity: 0.4 + text: isAttribute && (model.isLink || isLinkChild) ? MaterialIcons.link : MaterialIcons.lock + } + } + } + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Shapes/Editor/ShapeEditor.qml b/meshroom/ui/qml/Shapes/Editor/ShapeEditor.qml new file mode 100644 index 0000000000..c7727c2225 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Editor/ShapeEditor.qml @@ -0,0 +1,78 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +/** +* ShapeEditor +* +* @biref A component to display and edit the shape attributes and shape files +* of the current node. +* @param model - the given current node list of attributes +* @param filterText - the given label filter string +*/ +Item { + id: shapeEditor + + // Properties + property alias model: attributeslist.model + property string filterText: "" + + Pane { + anchors.fill: parent + anchors.margins: 2 + padding: 5 + background: Rectangle { color: Qt.darker(parent.palette.window, 1.4) } + + ScrollView { + anchors.fill: parent + + // Disable horizontal scroll + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Shape attributes + ListView { + id: attributeslist + spacing: 0 + interactive: false + + // Layout + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + + delegate: ShapeEditorItem { + model: object + active: object.hasDisplayableShape && object.matchText(filterText) + width: ListView.view.width + } + } + + // Shape files + ListView { + spacing: 0 + interactive: false + + // Layout + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + + model: ShapeFilesHelper.nodeShapeFiles + delegate: ShapeEditorItem { + model: object + width: ListView.view.width + } + } + } + + // Reset selection + TapHandler { + acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.WithinBounds + onTapped: { ShapeViewerHelper.selectedShapeName = "" } + } + } + } +} diff --git a/meshroom/ui/qml/Shapes/Editor/ShapeEditorItem.qml b/meshroom/ui/qml/Shapes/Editor/ShapeEditorItem.qml new file mode 100644 index 0000000000..54762a230c --- /dev/null +++ b/meshroom/ui/qml/Shapes/Editor/ShapeEditorItem.qml @@ -0,0 +1,44 @@ +import QtQuick + +import "Items" as ShapeEditorItems + +/** +* ShapeEditorItem +* +* @biref ShapeEditor item loader. +* Choose the correct component for each models +* @param model - the given ShapeAttribute / ShapeListAttribute / ShapeFile +*/ +Loader { + id: itemLoader + + // Properties + property var model: null + + // Source component + sourceComponent: { + switch(model.type) { + case "ShapeFile": return shapeFileComponent + case "ShapeList": return shapeListAttributeComponent + default: return shapeAttributeComponent + } + } + + // ShapeFile component + Component { + id: shapeFileComponent + ShapeEditorItems.ShapeFileItem { shapeFile: itemLoader.model } + } + + // ShapeListAttribute component + Component { + id: shapeListAttributeComponent + ShapeEditorItems.ShapeListAttributeItem { shapeListAttribute: itemLoader.model } + } + + // ShapeAttribute component + Component { + id: shapeAttributeComponent; + ShapeEditorItems.ShapeAttributeItem { shapeAttribute: itemLoader.model } + } +} diff --git a/meshroom/ui/qml/Shapes/Viewer/Layers/BaseLayer.qml b/meshroom/ui/qml/Shapes/Viewer/Layers/BaseLayer.qml new file mode 100644 index 0000000000..0085f1ee69 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/Layers/BaseLayer.qml @@ -0,0 +1,70 @@ +import QtQuick + +/** +* BaseLayer +* +* @biref Shape layer base component for displaying and modifying shapes. +* @param name - the given shape name +* @param properties - the given shape style properties +* @param observation - the given shape position and dimensions for the current view +* @param editable - the shape is editable +* @param scaleRatio - the shape container scale ratio (scroll zoom) +* @param selected - the shape is selected +*/ +Item { + id: baseLayer + + // Shape layer fills the parent + anchors.fill: parent + + // Shape name + property string name: "unknown" + + // Shape properties + property var properties: ({}) + + // Shape observation + property var observation: ({}) + + // Shape is editable + property bool editable: false + + // Shape container scale ratio + property real scaleRatio: 1.0 + + // Shape is selected + property bool selected: ShapeViewerHelper.selectedShapeName === name + + // Shape default color + readonly property color defaultColor: "#3366cc" + + // Request selection + function selectionRequested() { + ShapeViewerHelper.selectedShapeName = name + } + + // Helper function to get scaled point size + function getScaledPointSize() { + return Math.max(0.5, (baseLayer.properties.size || 10.0) * baseLayer.scaleRatio) + } + + // Helper function to get scaled handle size + function getScaledHandleSize() { + return Math.max(1.0, 8.0 * scaleRatio) + } + + // Helper function to get scaled stroke width + function getScaledStrokeWidth() { + return Math.max(0.05, (baseLayer.properties.strokeWidth || 2.0) * baseLayer.scaleRatio) + } + + // Helper function to get scaled helper stroke width + function getScaledHelperStrokeWidth() { + return Math.max(0.05, baseLayer.scaleRatio) + } + + // Helper function to get scaled font size + function getScaledFontSize() { + return Math.max(4.0, (baseLayer.properties.fontSize || 10.0) * baseLayer.scaleRatio) + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Shapes/Viewer/Layers/CircleLayer.qml b/meshroom/ui/qml/Shapes/Viewer/Layers/CircleLayer.qml new file mode 100644 index 0000000000..b1e05183d2 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/Layers/CircleLayer.qml @@ -0,0 +1,108 @@ +import QtQuick +import QtQuick.Shapes + +import "Utils" as LayerUtils + +/** +* CircleLayer +* +* @biref Allows to display and modify a circle. +* @param name - the given shape name +* @param properties - the given shape style properties +* @param observation - the given shape position and dimensions for the current view +* @param editable - the shape is editable +* @param scaleRatio - the shape container scale ratio (scroll zoom) +* @param selected - the shape is selected +* @see BaseLayer.qml +*/ +BaseLayer { + id: circleLayer + + // Circle radius from handleRadius position + property real circleRadius: Math.max(1.0, Math.sqrt(Math.pow(handleRadius.x - handleCenter.x, 2) + + Math.pow(handleRadius.y - handleCenter.y, 2))) + + // Circle shape + Shape { + id: draggableShape + + // Circle path + ShapePath { + fillColor: circleLayer.properties.fillColor || "transparent" + strokeColor: circleLayer.properties.strokeColor || circleLayer.properties.color || circleLayer.defaultColor + strokeWidth: getScaledStrokeWidth() + + // Circle + PathRectangle { + x: circleLayer.observation.center.x - circleRadius + y: circleLayer.observation.center.y - circleRadius + width: circleRadius * 2 + height: circleRadius * 2 + radius: circleRadius + } + + // Center cross + PathMove { x: circleLayer.observation.center.x - 10; y: circleLayer.observation.center.y } + PathLine { x: circleLayer.observation.center.x + 10; y: circleLayer.observation.center.y } + PathMove { x: circleLayer.observation.center.x; y: circleLayer.observation.center.y - 10 } + PathLine { x: circleLayer.observation.center.x; y: circleLayer.observation.center.y + 10 } + } + + // Radius helper path + ShapePath { + fillColor: "transparent" + strokeColor: circleLayer.selected ? "#bbffffff" : "transparent" + strokeWidth: getScaledHelperStrokeWidth() + + PathMove { x: circleLayer.observation.center.x; y: circleLayer.observation.center.y } + PathLine { x: handleRadius.x; y: handleRadius.y } + } + + // Selection area + MouseArea { + x: handleCenter.x - circleRadius + y: handleCenter.y - circleRadius + width: circleRadius * 2 + height: circleRadius * 2 + acceptedButtons: Qt.LeftButton + cursorShape: circleLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: selectionRequested() + enabled: circleLayer.editable && !circleLayer.selected + } + + // Handle for circle center + LayerUtils.Handle { + id: handleCenter + x: circleLayer.observation.center.x || 0 + y: circleLayer.observation.center.y || 0 + size: getScaledHandleSize() + target: draggableShape + cursorShape: Qt.SizeAllCursor + visible: circleLayer.editable && circleLayer.selected + onMoved: { + _reconstruction.setObservationFromName(circleLayer.name, _reconstruction.selectedViewId, { + center: { + x: handleCenter.x + draggableShape.x, + y: handleCenter.y + draggableShape.y + } + }) + } + } + + // Handle for circle radius + LayerUtils.Handle { + id: handleRadius + x: circleLayer.observation.center.x + circleLayer.observation.radius || 0 + y: circleLayer.observation.center.y || 0 + size: getScaledHandleSize() + cursorShape: Qt.SizeBDiagCursor + visible: circleLayer.editable && circleLayer.selected + onMoved: { + _reconstruction.setObservationFromName(circleLayer.name, _reconstruction.selectedViewId, { + radius: circleRadius + }) + } + } + } +} + diff --git a/meshroom/ui/qml/Shapes/Viewer/Layers/LineLayer.qml b/meshroom/ui/qml/Shapes/Viewer/Layers/LineLayer.qml new file mode 100644 index 0000000000..6f2c05d2a9 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/Layers/LineLayer.qml @@ -0,0 +1,126 @@ +import QtQuick +import QtQuick.Shapes + +import "Utils" as LayerUtils + +/** +* LineLayer +* +* @biref Allows to display and modify a line. +* @param name - the given shape name +* @param properties - the given shape style properties +* @param observation - the given shape position and dimensions for the current view +* @param editable - the shape is editable +* @param scaleRatio - the shape container scale ratio (scroll zoom) +* @param selected - the shape is selected +* @see BaseLayer.qml +*/ +BaseLayer { + id: lineLayer + + // Line center from handleA and handleB position + property point lineCenter: Qt.point((handleA.x + handleB.x) * 0.5, (handleA.y + handleB.y) * 0.5) + // Line angle from handleA and handleB position + property real lineAngle: Math.atan2(handleB.y - handleA.y, handleB.x - handleA.x) + // Line distance from handleA and handleB position + property real lineDistance: Math.max(1.0, Math.sqrt(Math.pow(handleA.x - handleB.x, 2) + + Math.pow(handleA.y - handleB.y, 2))) + + // Line shape + Shape { + id: draggableLine + + // Line path + ShapePath { + fillColor: "transparent" + strokeColor: lineLayer.properties.strokeColor || lineLayer.properties.color || lineLayer.defaultColor + strokeWidth: getScaledStrokeWidth() + + // Line + PathMove { x: handleA.x; y: handleA.y } + PathLine { x: handleB.x; y: handleB.y } + + // Orientation center arrow + PathMove { + x: lineCenter.x - lineDistance * 0.1 * Math.cos(lineAngle - Math.PI * 0.25) + y: lineCenter.y - lineDistance * 0.1 * Math.sin(lineAngle - Math.PI * 0.25) + } + PathLine { x: lineCenter.x; y: lineCenter.y } + PathLine { + x: lineCenter.x - lineDistance * 0.1 * Math.cos(lineAngle + Math.PI * 0.25) + y: lineCenter.y - lineDistance * 0.1 * Math.sin(lineAngle + Math.PI * 0.25) + } + } + + // Selection area + MouseArea { + x: Math.min(handleA.x, handleB.x) + y: Math.min(handleA.y, handleB.y) + width: Math.abs(handleA.x - handleB.x) + height: Math.abs(handleA.y - handleB.y) + acceptedButtons: Qt.LeftButton + cursorShape: lineLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: selectionRequested() + enabled: lineLayer.editable && !lineLayer.selected + } + + // Handle for point A + LayerUtils.Handle { + id: handleA + x: lineLayer.observation.a.x || 0 + y: lineLayer.observation.a.y || 0 + size: getScaledHandleSize() + cursorShape: Qt.SizeAllCursor + visible: lineLayer.editable && lineLayer.selected + onMoved: { + _reconstruction.setObservationFromName(lineLayer.name, _reconstruction.selectedViewId, { + a: { + x: handleA.x + draggableLine.x, + y: handleA.y + draggableLine.y + } + }) + } + } + + // Handle for point B + LayerUtils.Handle { + id: handleB + x: lineLayer.observation.b.x || 0 + y: lineLayer.observation.b.y || 0 + size: getScaledHandleSize() + cursorShape: Qt.SizeAllCursor + visible: lineLayer.editable && lineLayer.selected + onMoved: { + _reconstruction.setObservationFromName(lineLayer.name, _reconstruction.selectedViewId, { + b: { + x: handleB.x + draggableLine.x, + y: handleB.y + draggableLine.y + } + }) + } + } + + // Handle for line center + LayerUtils.Handle { + id: handleCenter + x: lineCenter.x + y: lineCenter.y + size: getScaledHandleSize() + target: draggableLine + cursorShape: Qt.SizeAllCursor + visible: lineLayer.editable && lineLayer.selected + onMoved: { + _reconstruction.setObservationFromName(lineLayer.name, _reconstruction.selectedViewId, { + a: { + x: handleA.x + draggableLine.x, + y: handleA.y + draggableLine.y + }, + b: { + x: handleB.x + draggableLine.x, + y: handleB.y + draggableLine.y + } + }) + } + } + } +} diff --git a/meshroom/ui/qml/Shapes/Viewer/Layers/PointLayer.qml b/meshroom/ui/qml/Shapes/Viewer/Layers/PointLayer.qml new file mode 100644 index 0000000000..216647d7a5 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/Layers/PointLayer.qml @@ -0,0 +1,91 @@ +import QtQuick + +/** +* PointLayer +* +* @biref Allows to display and modify a 2d point. +* @param name - the given shape name +* @param properties - the given shape style properties +* @param observation - the given shape position and dimensions for the current view +* @param editable - the shape is editable +* @param scaleRatio - the shape container scale ratio (scroll zoom) +* @param selected - the shape is selected +* @see BaseLayer.qml +*/ +BaseLayer { + id: pointLayer + + // Point size from scaled properties.size + property real pointSize: pointLayer.getScaledPointSize() + + // Point shape + Rectangle { + id: draggablePoint + x: pointLayer.observation.x - (pointSize * 0.5) + y: pointLayer.observation.y - (pointSize * 0.5) + width: pointSize + height: width + color: selected ? "#ffffff" : pointLayer.properties.color || pointLayer.defaultColor + + // Selection click + TapHandler { + acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.WithinBounds + grabPermissions: PointerHandler.CanTakeOverFromAnything + margin: pointSize + onTapped: selectionRequested() + enabled: pointLayer.editable && !pointLayer.selected + } + + // Selection hover + HoverHandler { + cursorShape: pointLayer.selected ? Qt.SizeAllCursor : Qt.PointingHandCursor + grabPermissions: PointerHandler.CanTakeOverFromAnything + margin: pointSize + enabled: pointLayer.editable + } + + // Drag + DragHandler { + target: draggablePoint + cursorShape: Qt.SizeAllCursor + enabled: pointLayer.editable && pointLayer.selected + onActiveChanged: { + if (!active) { + _reconstruction.setObservationFromName(pointLayer.name, _reconstruction.selectedViewId, { + x: draggablePoint.x + pointSize * 0.5, + y: draggablePoint.y + pointSize * 0.5 + }) + } + } + } + + // Point name + Text { + x: pointSize + y: pointSize + text: { + const lastDotIndex = pointLayer.name.lastIndexOf('.') + if(lastDotIndex < 0) + return pointLayer.name + return pointLayer.name.substring(lastDotIndex + 1); + } + color: draggablePoint.color + wrapMode: Text.NoWrap + font.pixelSize: getScaledFontSize() + visible: pointLayer.editable && scaleRatio > 0.1 + } + } +} + + + + + + + + + + + + diff --git a/meshroom/ui/qml/Shapes/Viewer/Layers/RectangleLayer.qml b/meshroom/ui/qml/Shapes/Viewer/Layers/RectangleLayer.qml new file mode 100644 index 0000000000..8cc9a96ffc --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/Layers/RectangleLayer.qml @@ -0,0 +1,126 @@ +import QtQuick +import QtQuick.Shapes + +import "Utils" as LayerUtils + +/** +* RectangleLayer +* +* @biref Allows to display and modify a rectangle. +* @param name - the given shape name +* @param properties - the given shape style properties +* @param observation - the given shape position and dimensions for the current view +* @param editable - the shape is editable +* @param scaleRatio - the shape container scale ratio (scroll zoom) +* @param selected - the shape is selected +* @see BaseLayer.qml +*/ +BaseLayer { + id: rectangleLayer + + // Rectangle width from handleWidth position + property real rectangleWidth: Math.max(1.0, Math.abs(handleCenter.x- handleWidth.x) * 2) + + // Rectangle height from handleHeight position + property real rectangleHeight: Math.max(1.0, Math.abs(handleCenter.y - handleHeight.y) * 2) + + // Rectangle shape + Shape { + id : draggableRectangle + + // Rectangle path + ShapePath { + fillColor: rectangleLayer.properties.fillColor || "transparent" + strokeColor: rectangleLayer.properties.strokeColor || rectangleLayer.properties.color || rectangleLayer.defaultColor + strokeWidth: getScaledStrokeWidth() + + PathRectangle { + x: rectangleLayer.observation.center.x - (rectangleWidth * 0.5) + y: rectangleLayer.observation.center.y - (rectangleHeight * 0.5) + width: rectangleWidth + height: rectangleHeight + } + } + + // Size helper path + ShapePath { + fillColor: "transparent" + strokeColor: rectangleLayer.selected ? "#bbffffff" : "transparent" + strokeWidth: getScaledHelperStrokeWidth() + + PathMove { x: rectangleLayer.observation.center.x; y: rectangleLayer.observation.center.y } + PathLine { x: handleWidth.x; y: handleWidth.y } + PathMove { x: rectangleLayer.observation.center.x; y: rectangleLayer.observation.center.y } + PathLine { x: handleHeight.x; y: handleHeight.y } + } + + // Selection area + MouseArea { + x: handleCenter.x - rectangleWidth * 0.5 + y: handleCenter.y - rectangleHeight * 0.5 + width: rectangleWidth + height: rectangleHeight + acceptedButtons: Qt.LeftButton + cursorShape: rectangleLayer.editable ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: selectionRequested() + enabled: rectangleLayer.editable && !rectangleLayer.selected + } + + // Handle for rectangle center + LayerUtils.Handle { + id: handleCenter + x: rectangleLayer.observation.center.x || 0 + y: rectangleLayer.observation.center.y || 0 + size: getScaledHandleSize() + target: draggableRectangle + cursorShape: Qt.SizeAllCursor + visible: rectangleLayer.editable && rectangleLayer.selected + onMoved: { + _reconstruction.setObservationFromName(rectangleLayer.name, _reconstruction.selectedViewId, { + center: { + x: handleCenter.x + draggableRectangle.x, + y: handleCenter.y + draggableRectangle.y, + } + }) + } + } + + // Handle for rectangle width + LayerUtils.Handle { + id: handleWidth + x: rectangleLayer.observation.center.x + (rectangleLayer.observation.size.width * 0.5) || 0 + y: handleCenter.y || 0 + size: getScaledHandleSize() + yAxisEnabled: false + cursorShape: Qt.SizeHorCursor + visible: rectangleLayer.editable && rectangleLayer.selected + onMoved: { + _reconstruction.setObservationFromName(rectangleLayer.name, _reconstruction.selectedViewId, { + size: { + width: rectangleWidth, + height: rectangleHeight + } + }) + } + } + + // Handle for rectangle height + LayerUtils.Handle { + id: handleHeight + x: rectangleLayer.observation.center.x || 0 + y: rectangleLayer.observation.center.y - (rectangleLayer.observation.size.height * 0.5) || 0 + size: getScaledHandleSize() + xAxisEnabled: false + cursorShape: Qt.SizeVerCursor + visible: rectangleLayer.editable && rectangleLayer.selected + onMoved: { + _reconstruction.setObservationFromName(rectangleLayer.name, _reconstruction.selectedViewId, { + size: { + width: rectangleWidth, + height: rectangleHeight + } + }) + } + } + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Shapes/Viewer/Layers/TextLayer.qml b/meshroom/ui/qml/Shapes/Viewer/Layers/TextLayer.qml new file mode 100644 index 0000000000..51c2c3693c --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/Layers/TextLayer.qml @@ -0,0 +1,27 @@ +import QtQuick + +/** +* TextLayer +* +* @biref Allows to display a text. +* @param name - the given shape name +* @param properties - the given shape style properties +* @param observation - the given shape position and dimensions for the current view +* @param editable - the shape is editable +* @param scaleRatio - the shape container scale ratio (scroll zoom) +* @param selected - the shape is selected +* @see BaseLayer.qml +*/ +BaseLayer { + id: textLayer + + Text { + x: textLayer.observation.center.x - implicitWidth * 0.5 // Center text horizontally + y: textLayer.observation.center.y - implicitHeight * 0.5 // Center text vertically + text: textLayer.observation.content || "Undefined" + color: textLayer.properties.color || textLayer.defaultColor + wrapMode: Text.NoWrap + font.family: textLayer.properties.fontFamily || "Arial" + font.pixelSize: getScaledFontSize() + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Shapes/Viewer/Layers/Utils/Handle.qml b/meshroom/ui/qml/Shapes/Viewer/Layers/Utils/Handle.qml new file mode 100644 index 0000000000..51d05ffdcf --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/Layers/Utils/Handle.qml @@ -0,0 +1,67 @@ +import QtQuick + +/** +* Handle +* +* @biref Handle component to centralize handle behavior and avoid code duplication. +* @param size - the handle display size +* @param target - the handle drag target +* @param xAxisEnabled - the handle x-axis is draggable +* @param yAxisEnabled - the handle y-axis is draggable +* @param cursorShape - the handle cursor shape +*/ +Rectangle { + id: root + + // Handle moved signal + signal moved() + + // Handle display size + property real size : 10.0 + + // Handle drag target + property alias target: dragHandler.target + + // Handle drag x-axis enabled + property bool xAxisEnabled : true + + // Handle drag y-axis enabled + property bool yAxisEnabled : true + + // Handle cursor shape + property alias cursorShape : dragHandler.cursorShape + + // Handle does not have a true size + // Width and height should always be 0 + width: 0 + height: 0 + + // Handle hover handler + HoverHandler { + cursorShape: dragHandler.cursorShape + grabPermissions: PointerHandler.CanTakeOverFromAnything + margin: root.size * 2 // Handle interaction area + enabled: root.visible + } + + // Handle drag handler + DragHandler { + id: dragHandler + cursorShape: Qt.SizeBDiagCursor + grabPermissions: PointerHandler.CanTakeOverFromAnything + xAxis.enabled: root.xAxisEnabled + yAxis.enabled: root.yAxisEnabled + margin: root.size * 2 // Handle interaction area + onActiveChanged: { if (!active) { root.moved() } } + enabled: root.visible + } + + // Handle shape + Rectangle { + x: root.size * -0.5 + y: root.size * -0.5 + width: root.size + height: root.size + color: "#ffffff" + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Shapes/Viewer/ShapeViewer.qml b/meshroom/ui/qml/Shapes/Viewer/ShapeViewer.qml new file mode 100644 index 0000000000..a2ef5a80cf --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/ShapeViewer.qml @@ -0,0 +1,65 @@ +import QtQuick + +/** +* ShapeViewer +* +* @biref A canvas to display current node shape attributes and shape files. +* @param containerWidth - the parent image container width +* @param containerHeight - the parent image container height +* @param containerScale - the parent image container scale +*/ +Item { + id: shapeViewer + + // Current node + property var node: _reconstruction ? _reconstruction.selectedNode : null + + // Container dimensions and scale + property real containerWidth: 0.0 + property real containerHeight: 0.0 + property real containerScale: 1.0 + + // Container scale ratio + property real scaleRatio: (1 / containerScale) + + // Update ShapeViewerHelper + // This is usefull for new observation initialization + onContainerWidthChanged: { ShapeViewerHelper.containerWidth = shapeViewer.containerWidth } + onContainerHeightChanged: { ShapeViewerHelper.containerHeight = shapeViewer.containerHeight } + onContainerScaleChanged: { ShapeViewerHelper.containerScale = shapeViewer.containerScale } + + // Current node shape files + // ShapeFilesHelper provide the model + Repeater { + model: ShapeFilesHelper.nodeShapeFiles + delegate: Repeater { + model: object.shapes + delegate: ShapeViewerLayer { + active: object.isVisible + scaleRatio: shapeViewer.scaleRatio + name: object.name + type: object.type + properties: object.properties + observation: object.observation + editable: false + } + } + } + + // Current node shape attributes + // Node attributes as the model + Repeater { + model: node.attributes + delegate: ShapeViewerAttributeLoader { + attribute: object + scaleRatio: shapeViewer.scaleRatio + } + } + + // Reset selection + TapHandler { + acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.WithinBounds + onTapped: { ShapeViewerHelper.selectedShapeName = "" } + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLayer.qml b/meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLayer.qml new file mode 100644 index 0000000000..d535a7da05 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLayer.qml @@ -0,0 +1,47 @@ +import QtQuick + +/** +* ShapeViewerAttributeLayer +* +* @biref Shape attribute layer loader. +* @param shapeAttribute - the given shape attribute +* @param isLinkChild - Whether the given attribute is a child of a linked attribute +* @param scaleRatio - the container scale ratio (scroll zoom) +*/ +Loader { + + // Properties + property var shapeAttribute + property bool isLinkChild: false + property real scaleRatio: 1.0 + + // Source component + sourceComponent: shapeAttributeLayerComponent + + // Reload source component + // When attribute observations changed (signal) + // For now, ShapeLayer should be re-build when observation changed + Connections { + target: shapeAttribute + function onObservationsChanged() { + sourceComponent = null + sourceComponent = shapeAttributeLayerComponent + } + } + + // Shape attribute layer component + Component { + id: shapeAttributeLayerComponent + Loader { + sourceComponent: ShapeViewerLayer { + scaleRatio: shapeViewer.scaleRatio + name: shapeAttribute.fullName + type: shapeAttribute.type + properties: ({"color" : shapeAttribute.shapeColor}) + observation: shapeAttribute.getObservation(_reconstruction ? _reconstruction.selectedViewId : "-1") + editable: shapeAttribute.enabled && !shapeAttribute.isLink && !isLinkChild + } + } + } +} + diff --git a/meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLoader.qml b/meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLoader.qml new file mode 100644 index 0000000000..cc076e87ed --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/ShapeViewerAttributeLoader.qml @@ -0,0 +1,50 @@ +import QtQuick + +/** +* ShapeViewerAttributeLoader +* +* @biref ShapeViewer attribute loader. +* @param attribute - the given attribute (ShapeAttribute or ShapeListAttribute) +* @param scaleRatio - the container scale ratio (scroll zoom) +*/ +Loader { + id: attributeLoader + + // Properties + property var attribute + property real scaleRatio: 1.0 + + // Attribute should be shape or shape list + // Attribute should be visible and not default + active: attribute.hasDisplayableShape && attribute.isVisible && !attribute.isDefault + + // Source component + sourceComponent: { + if(attribute.type === "ShapeList") + return shapeListAttributeComponent + return shapeAttributeComponent + } + + // Shape attribute component + Component { + id: shapeAttributeComponent + ShapeViewerAttributeLayer { + shapeAttribute: attribute + scaleRatio: attributeLoader.scaleRatio + } + } + + // Shape list attribute component + Component { + id: shapeListAttributeComponent + Repeater { + model: attribute.value + delegate: ShapeViewerAttributeLayer { + active: object.isVisible && !object.isDefault + shapeAttribute: object + isLinkChild: attribute.isLink + scaleRatio: attributeLoader.scaleRatio + } + } + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Shapes/Viewer/ShapeViewerLayer.qml b/meshroom/ui/qml/Shapes/Viewer/ShapeViewerLayer.qml new file mode 100644 index 0000000000..c3fc2af3c6 --- /dev/null +++ b/meshroom/ui/qml/Shapes/Viewer/ShapeViewerLayer.qml @@ -0,0 +1,98 @@ +import QtQuick +import "Layers" as ShapeViewerLayers + +/** +* ShapeViewerLayer +* +* @biref Load the corresponding shape layer. +* @param type - the given shape type +* @param name - the given shape name +* @param properties - the given shape style properties +* @param observation - the given shape position and dimensions for the current view +* @param editable - the shape is editable +* @param scaleRatio - the container scale ratio (scroll zoom) +*/ +Loader { + id: layerLoader + + // Properties + property string type + property string name + property var properties + property var observation + property bool editable: false + property real scaleRatio: 1.0 + + // Source component + sourceComponent: { + if (!properties || !observation) + return; + switch (type) { + case "Point2d": return pointLayerComponent + case "Line2d": return lineLayerComponent + case "Circle": return circleLayerComponent + case "Rectangle": return rectangleLayerComponent + case "Text": return textLayerComponent + } + } + + // PointLayer component + Component { + id: pointLayerComponent + ShapeViewerLayers.PointLayer { + name: layerLoader.name + properties: layerLoader.properties + observation: layerLoader.observation + editable: layerLoader.editable + scaleRatio: layerLoader.scaleRatio + } + } + + // LineLayer component + Component { + id: lineLayerComponent + ShapeViewerLayers.LineLayer { + name: layerLoader.name + properties: layerLoader.properties + observation: layerLoader.observation + editable: layerLoader.editable + scaleRatio: layerLoader.scaleRatio + } + } + + // CircleLayer component + Component { + id: circleLayerComponent + ShapeViewerLayers.CircleLayer { + name: layerLoader.name + properties: layerLoader.properties + observation: layerLoader.observation + editable: layerLoader.editable + scaleRatio: layerLoader.scaleRatio + } + } + + // RectangleLayer component + Component { + id: rectangleLayerComponent + ShapeViewerLayers.RectangleLayer { + name: layerLoader.name + properties: layerLoader.properties + observation: layerLoader.observation + editable: layerLoader.editable + scaleRatio: layerLoader.scaleRatio + } + } + + // TextLayer component + Component { + id: textLayerComponent + ShapeViewerLayers.TextLayer { + name: layerLoader.name + properties: layerLoader.properties + observation: layerLoader.observation + editable: layerLoader.editable + scaleRatio: layerLoader.scaleRatio + } + } +} diff --git a/meshroom/ui/qml/Shapes/qmldir b/meshroom/ui/qml/Shapes/qmldir new file mode 100644 index 0000000000..6f71600bd7 --- /dev/null +++ b/meshroom/ui/qml/Shapes/qmldir @@ -0,0 +1,4 @@ +module Shapes + +ShapeEditor 1.0 Editor/ShapeEditor.qml +ShapeViewer 1.0 Viewer/ShapeViewer.qml \ No newline at end of file diff --git a/meshroom/ui/qml/Viewer/Viewer2D.qml b/meshroom/ui/qml/Viewer/Viewer2D.qml index 6a45652240..dcb78e8b4e 100644 --- a/meshroom/ui/qml/Viewer/Viewer2D.qml +++ b/meshroom/ui/qml/Viewer/Viewer2D.qml @@ -832,6 +832,32 @@ FocusScope { } } + // ShapeViewer: display shapes and texts from current node shape attributes and json files + // Note: use a Loader + ExifOrientedViewer { + anchors.centerIn: parent + width: imgContainer.width + height: imgContainer.height + xOrigin: imgContainer.width * 0.5 + yOrigin: imgContainer.height * 0.5 + orientationTag: imgContainer.orientationTag + active: _reconstruction ? (_reconstruction.selectedNode ? _reconstruction.selectedNode.hasDisplayableShape : false) : false + + onActiveChanged: { + if (active) { + setSource("../Shapes/Viewer/ShapeViewer.qml", { + "containerWidth": Qt.binding(function() { return imgContainer.width }), + "containerHeight": Qt.binding(function() { return imgContainer.height }), + "containerScale": Qt.binding(function() { return imgContainer.scale }) + }) + } else { + // forcing the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 + setSource("", {}) + + } + } + } + // FisheyeCircleViewer: display fisheye circle // Note: use a Loader to evaluate if a PanoramaInit node exist and displayFisheyeCircle checked at runtime ExifOrientedViewer { diff --git a/tests/test_attributeShape.py b/tests/test_attributeShape.py new file mode 100644 index 0000000000..cc709d9598 --- /dev/null +++ b/tests/test_attributeShape.py @@ -0,0 +1,477 @@ +from meshroom.core import desc +from meshroom.core.graph import Graph + +from .utils import registerNodeDesc, unregisterNodeDesc + + +class NodeWithShapeAttributes(desc.Node): + inputs = [ + desc.ShapeList( + name="pointList", + label="Point 2d List", + description="Point 2d list.", + shape=desc.Point2d( + name="point", + label="Point", + description="A 2d point.", + ), + ), + desc.ShapeList( + name="keyablePointList", + label="Keyable Point 2d List", + description="Keyable point 2d list.", + shape=desc.Point2d( + name="point", + label="Point", + description="A 2d point.", + keyable=True, + keyType="viewId" + ), + ), + desc.Point2d( + name="point", + label="Point 2d", + description="A 2d point.", + ), + desc.Point2d( + name="keyablePoint", + label="Keyable Point 2d", + description="A keyable 2d point.", + keyable=True, + keyType="viewId" + ), + desc.Line2d( + name="line", + label="Line 2d", + description="A 2d line.", + ), + desc.Line2d( + name="keyableLine", + label="Keyable Line 2d", + description="A keyable 2d line.", + keyable=True, + keyType="viewId" + ), + desc.Rectangle( + name="rectangle", + label="Rectangle", + description="A rectangle.", + ), + desc.Rectangle( + name="keyableRectangle", + label="Keyable Rectangle", + description="A keyable rectangle.", + keyable=True, + keyType="viewId" + ), + desc.Circle( + name="circle", + label="Circle", + description="A circle.", + ), + desc.Circle( + name="keyableCircle", + label="Keyable Circle", + description="A keyable circle.", + keyable=True, + keyType="viewId" + ), + ] + +class TestShapeAttribute: + + @classmethod + def setup_class(cls): + registerNodeDesc(NodeWithShapeAttributes) + + @classmethod + def teardown_class(cls): + unregisterNodeDesc(NodeWithShapeAttributes) + + def test_initialization(self): + graph = Graph("") + node = graph.addNewNode(NodeWithShapeAttributes.__name__) + + # ShapeListAttribute initialization + + # Check attribute has displayable shape (should be true) + assert node.pointList.hasDisplayableShape + assert node.keyablePointList.hasDisplayableShape + + # Check attribute type + assert node.pointList.type == "ShapeList" + assert node.keyablePointList.type == "ShapeList" + + # Check length + # Should be 0, empty list + assert len(node.pointList) == 0 + assert len(node.keyablePointList) == 0 + + # ShapeAttribute initialization + + # Check attribute has displayable shape (should be true) + assert node.point.hasDisplayableShape + assert node.line.hasDisplayableShape + assert node.rectangle.hasDisplayableShape + assert node.circle.hasDisplayableShape + assert node.keyablePoint.hasDisplayableShape + assert node.keyableLine.hasDisplayableShape + assert node.keyableRectangle.hasDisplayableShape + assert node.keyableCircle.hasDisplayableShape + + # Check attribute type + assert node.point.type == "Point2d" + assert node.line.type == "Line2d" + assert node.rectangle.type == "Rectangle" + assert node.circle.type == "Circle" + assert node.keyablePoint.type == "Point2d" + assert node.keyableLine.type == "Line2d" + assert node.keyableRectangle.type == "Rectangle" + assert node.keyableCircle.type == "Circle" + + # Check attribute number of observations + # Should be 1 for static shape (default) + assert node.point.nbObservations == 1 + assert node.line.nbObservations == 1 + assert node.rectangle.nbObservations == 1 + assert node.circle.nbObservations == 1 + # Should be 0 for keyable shape + assert node.keyablePoint.nbObservations == 0 + assert node.keyableLine.nbObservations == 0 + assert node.keyableRectangle.nbObservations == 0 + assert node.keyableCircle.nbObservations == 0 + + # Check attribute shape keyable + # Should be false for static shape + assert not node.point.shapeKeyable + assert not node.line.shapeKeyable + assert not node.rectangle.shapeKeyable + assert not node.circle.shapeKeyable + # Should be true for keyable shape + assert node.keyablePoint.shapeKeyable + assert node.keyableLine.shapeKeyable + assert node.keyableRectangle.shapeKeyable + assert node.keyableCircle.shapeKeyable + + + def test_staticShape(self): + graph = Graph("") + node = graph.addNewNode(NodeWithShapeAttributes.__name__) + + observationPoint = {"x" : 1, "y" : 1} + observationLine = {"a" : {"x" : 1, "y" : 1}, "b" : {"x" : 2, "y" : 2}} + observationRectangle = {"center" : {"x" : 10, "y" : 10}, "size" : {"width" : 20, "height" : 20}} + observationCircle = {"center" : {"x" : 10, "y" : 10}, "radius" : 20} + + # Check attribute has observation, should be true (default) + assert node.point.hasObservation("0") + assert node.line.hasObservation("0") + assert node.rectangle.hasObservation("0") + assert node.circle.hasObservation("0") + + # Check attribute get observation, should be default value + assert node.point.getObservation("0") == node.point.getDefaultValue() + assert node.line.getObservation("0") == node.line.getDefaultValue() + assert node.rectangle.getObservation("0") == node.rectangle.getDefaultValue() + assert node.circle.getObservation("0") == node.circle.getDefaultValue() + + # Create observation at key "0" + # Attribute are not keyable, key has no effect + node.point.setObservation("0", observationPoint) + node.line.setObservation("0", observationLine) + node.rectangle.setObservation("0", observationRectangle) + node.circle.setObservation("0", observationCircle) + + # Check attribute has observation, should be true + assert node.point.hasObservation("0") + assert node.line.hasObservation("0") + assert node.rectangle.hasObservation("0") + assert node.circle.hasObservation("0") + + # Check attribute get observation, should be created observation + assert node.point.getObservation("0") == observationPoint + assert node.line.getObservation("0") == observationLine + assert node.rectangle.getObservation("0") == observationRectangle + assert node.circle.getObservation("0") == observationCircle + + # Update attribute observation + node.point.setObservation("0", {"x" : 2}) + node.line.setObservation("0", {"a" : {"x" : 2, "y": 2}}) + node.rectangle.setObservation("0", {"center" : {"x" : 20, "y" : 20}}) + node.circle.setObservation("0", {"radius" : 40}) + + # Check attribute get observation, should be updated observation + assert node.point.getObservation("0").get("x") == 2 + assert node.line.getObservation("0").get("a") == {"x" : 2, "y": 2} + assert node.rectangle.getObservation("0").get("center") == {"x" : 20, "y" : 20} + assert node.circle.getObservation("0").get("radius") == 40 + + # Reset attribute value + node.point.resetToDefaultValue() + node.line.resetToDefaultValue() + node.rectangle.resetToDefaultValue() + node.circle.resetToDefaultValue() + + # Check attribute get observation, should be default value + assert node.point.getObservation("0") == node.point.getDefaultValue() + assert node.line.getObservation("0") == node.line.getDefaultValue() + assert node.rectangle.getObservation("0") == node.rectangle.getDefaultValue() + assert node.circle.getObservation("0") == node.circle.getDefaultValue() + + + def test_keyableShape(self): + graph = Graph("") + node = graph.addNewNode(NodeWithShapeAttributes.__name__) + + observationPoint = {"x" : 1, "y" : 1} + observationLine = {"a" : {"x" : 1, "y" : 1}, "b" : {"x" : 2, "y" : 2}} + observationRectangle = {"center" : {"x" : 10, "y" : 10}, "size" : {"width" : 20, "height" : 20}} + observationCircle = {"center" : {"x" : 10, "y" : 10}, "radius" : 20} + + # Check attribute has observation at key "0", should be false + assert not node.keyablePoint.hasObservation("0") + assert not node.keyableLine.hasObservation("0") + assert not node.keyableRectangle.hasObservation("0") + assert not node.keyableCircle.hasObservation("0") + + # Check attribute get observation at key "0", should be None (no observation) + assert node.keyablePoint.getObservation("0") == None + assert node.keyableLine.getObservation("0") == None + assert node.keyableRectangle.getObservation("0") == None + assert node.keyableCircle.getObservation("0") == None + + # Create observation at key "0" + node.keyablePoint.setObservation("0", observationPoint) + node.keyableLine.setObservation("0", observationLine) + node.keyableRectangle.setObservation("0", observationRectangle) + node.keyableCircle.setObservation("0", observationCircle) + + # Check attribute number of observations, should be 1 + assert node.keyablePoint.nbObservations == 1 + assert node.keyableLine.nbObservations == 1 + assert node.keyableRectangle.nbObservations == 1 + assert node.keyableCircle.nbObservations == 1 + + # Create observation at key "1" + node.keyablePoint.setObservation("1", observationPoint) + node.keyableLine.setObservation("1", observationLine) + node.keyableRectangle.setObservation("1", observationRectangle) + node.keyableCircle.setObservation("1", observationCircle) + + # Check attribute number of observations, should be 2 + assert node.keyablePoint.nbObservations == 2 + assert node.keyableLine.nbObservations == 2 + assert node.keyableRectangle.nbObservations == 2 + assert node.keyableCircle.nbObservations == 2 + + # Check attribute has observation, should be true + assert node.keyablePoint.hasObservation("0") + assert node.keyablePoint.hasObservation("1") + assert node.keyableLine.hasObservation("0") + assert node.keyableLine.hasObservation("1") + assert node.keyableRectangle.hasObservation("0") + assert node.keyableRectangle.hasObservation("1") + assert node.keyableCircle.hasObservation("0") + assert node.keyableCircle.hasObservation("1") + + # Check attribute get observation at key "0", should be created observation + assert node.keyablePoint.getObservation("0") == observationPoint + assert node.keyableLine.getObservation("0") == observationLine + assert node.keyableRectangle.getObservation("0") == observationRectangle + assert node.keyableCircle.getObservation("0") == observationCircle + + # Update attribute observation at key "1" + node.keyablePoint.setObservation("1", {"x" : 2}) + node.keyableLine.setObservation("1", {"a" : {"x" : 2, "y": 2}}) + node.keyableRectangle.setObservation("1", {"center" : {"x" : 20, "y" : 20}}) + node.keyableCircle.setObservation("1", {"radius" : 40}) + + # Check attribute get observation at key "1", should be updated observation + assert node.keyablePoint.getObservation("1").get("x") == 2 + assert node.keyableLine.getObservation("1").get("a") == {"x" : 2, "y": 2} + assert node.keyableRectangle.getObservation("1").get("center") == {"x" : 20, "y" : 20} + assert node.keyableCircle.getObservation("1").get("radius") == 40 + + # Remove attribute observation at key "0" + node.keyablePoint.removeObservation("0") + node.keyableLine.removeObservation("0") + node.keyableRectangle.removeObservation("0") + node.keyableCircle.removeObservation("0") + + # Check attribute has observation at key "0", should be false + assert not node.keyablePoint.hasObservation("0") + assert not node.keyableLine.hasObservation("0") + assert not node.keyableRectangle.hasObservation("0") + assert not node.keyableCircle.hasObservation("0") + + # Reset attribute value + node.keyablePoint.resetToDefaultValue() + node.keyableLine.resetToDefaultValue() + node.keyableRectangle.resetToDefaultValue() + node.keyableCircle.resetToDefaultValue() + + # Check attribute has observation at key "1", should be false + assert not node.keyablePoint.hasObservation("0") + assert not node.keyableLine.hasObservation("0") + assert not node.keyableRectangle.hasObservation("0") + assert not node.keyableCircle.hasObservation("0") + + # Check attribute number of observations, should be 0 + assert node.keyablePoint.nbObservations == 0 + assert node.keyableLine.nbObservations == 0 + assert node.keyableRectangle.nbObservations == 0 + assert node.keyableCircle.nbObservations == 0 + + def test_shapeList(self): + graph = Graph("") + node = graph.addNewNode(NodeWithShapeAttributes.__name__) + + pointValue = {"x" : 1, "y" : 1} + keyablePointValue = {} + + # Check visibility + assert node.pointList.isVisible + assert node.keyablePointList.isVisible + + # Check number of shapes, should be 0 (no shape) + assert len(node.pointList) == 0 + assert len(node.keyablePointList) == 0 + + # Add 3 elements + node.pointList.append(pointValue) + node.pointList.append(pointValue) + node.pointList.append(pointValue) + node.keyablePointList.append(keyablePointValue) + node.keyablePointList.append(keyablePointValue) + node.keyablePointList.append(keyablePointValue) + + # Check number of shapes, should be 3 + assert len(node.pointList) == 3 + assert len(node.keyablePointList) == 3 + + # Check attribute second element + assert node.pointList.at(1).getValueAsDict() == pointValue + assert node.keyablePointList.at(1).getValueAsDict() == keyablePointValue + + # Change visibility + node.pointList.isVisible = False + node.keyablePointList.isVisible = False + + # Check shapes visibility + assert not node.pointList.at(0).isVisible + assert not node.pointList.at(1).isVisible + assert not node.pointList.at(2).isVisible + assert not node.keyablePointList.at(0).isVisible + assert not node.keyablePointList.at(1).isVisible + assert not node.keyablePointList.at(2).isVisible + + # Reset shape lists + node.pointList.resetToDefaultValue() + node.keyablePointList.resetToDefaultValue() + + # Check number of shapes, should be 0 (no shape) + assert len(node.pointList) == 0 + assert len(node.keyablePointList) == 0 + + + def test_linkAttribute(self): + graph = Graph("") + nodeA = graph.addNewNode(NodeWithShapeAttributes.__name__) + nodeB = graph.addNewNode(NodeWithShapeAttributes.__name__) + + pointValue = {"x" : 1, "y" : 1} + + # Add link: + # nodeB.pointList is a link for nodeA.pointList + graph.addEdge(nodeA.pointList, nodeB.pointList) + # nodeB.point is a link for nodeA.point + graph.addEdge(nodeA.point, nodeB.point) + + # Check link + assert nodeB.pointList.isLink == True + assert nodeB.pointList.inputLink == nodeA.pointList + assert nodeB.point.isLink == True + assert nodeB.point.inputLink == nodeA.point + + # Set observation for nodeA.point + nodeA.point.setObservation("0", pointValue) + # Add 3 shape to nodeA.pointList + nodeA.pointList.append(pointValue) + nodeA.pointList.append(pointValue) + nodeA.pointList.append(pointValue) + + # Check nodeB.point + assert nodeB.point.getObservation(0) == pointValue + + # Check nodeB.pointList + assert len(nodeB.pointList) == 3 + assert nodeB.pointList.at(0).getValueAsDict() == pointValue + assert nodeB.pointList.at(1).getValueAsDict() == pointValue + assert nodeB.pointList.at(2).getValueAsDict() == pointValue + + # Update nodeA.point and nodeA.pointList[1] + nodeA.point.setObservation("0", {"x" : 2}) + nodeA.pointList.at(1).setObservation("0", {"x" : 2}) + + # Check nodeB second shape + assert nodeB.point.getObservation("0").get("x") == 2 + assert nodeB.pointList.at(1).getObservation("0").get("x") == 2 + + # Check serialized value + assert nodeB.point.getSerializedValue() == nodeA.point.asLinkExpr() + assert nodeB.pointList.getSerializedValue() == nodeA.pointList.asLinkExpr() + + + def test_valueAsDict(self): + graph = Graph("") + node = graph.addNewNode(NodeWithShapeAttributes.__name__) + + observationPoint = {"x" : 1, "y" : 1} + observationLine = {"a" : {"x" : 1, "y" : 1}, "b" : {"x" : 2, "y" : 2}} + observationRectangle = {"center" : {"x" : 10, "y" : 10}, "size" : {"width" : 20, "height" : 20}} + observationCircle = {"center" : {"x" : 10, "y" : 10}, "radius" : 20} + keyablePointValue = {"x" : {"0" : observationPoint.get("x")}, "y" : {"0" : observationPoint.get("y")}} + + # Check uninitialized shape attribute + # Shape list attribute should be empty list + assert node.pointList.getShapesAsDicts() == [] + assert node.keyablePointList.getShapesAsDicts() == [] + # Not keyable shape attribute should be default + assert node.point.getValueAsDict() == {"x" : -1, "y" : -1} + assert node.line.getValueAsDict() == {"a" : {"x" : -1, "y" : -1}, "b" : {"x" : -1, "y" : -1}} + assert node.rectangle.getValueAsDict() == {"center" : {"x" : -1, "y" : -1}, "size" : {"width" : -1, "height" : -1}} + assert node.circle.getValueAsDict() == {"center" : {"x" : -1, "y" : -1}, "radius" : -1} + # Keyable shape attribute should be empty dict + assert node.keyablePoint.getValueAsDict() == {} + assert node.keyableLine.getValueAsDict() == {} + assert node.keyableRectangle.getValueAsDict() == {} + assert node.keyableCircle.getValueAsDict() == {} + + # Add one shape with an observation + node.pointList.append(observationPoint) + node.keyablePointList.append(keyablePointValue) + + # Add one observation + node.point.setObservation("0", observationPoint) + node.keyablePoint.setObservation("0", observationPoint) + node.line.setObservation("0", observationLine) + node.keyableLine.setObservation("0", observationLine) + node.rectangle.setObservation("0", observationRectangle) + node.keyableRectangle.setObservation("0", observationRectangle) + node.circle.setObservation("0", observationCircle) + node.keyableCircle.setObservation("0", observationCircle) + + # Check shape attribute + # Shape list attribute should be empty dict + assert node.pointList.getShapesAsDicts() == [observationPoint] + assert node.keyablePointList.getShapesAsDicts() == [{"0" : observationPoint}] + # Not keyable shape attribute should be default + assert node.point.getValueAsDict() == observationPoint + assert node.line.getValueAsDict() == observationLine + assert node.rectangle.getValueAsDict() == observationRectangle + assert node.circle.getValueAsDict() == observationCircle + # Keyable shape attribute should be empty dict + assert node.keyablePoint.getValueAsDict() == {"0" : observationPoint} + assert node.keyableLine.getValueAsDict() == {"0" : observationLine} + assert node.keyableRectangle.getValueAsDict() == {"0" : observationRectangle} + assert node.keyableCircle.getValueAsDict() == {"0" : observationCircle} \ No newline at end of file