+ );
+};
diff --git a/src/components/Object3DView/Object3D.styl b/src/components/Object3DView/Object3D.styl
new file mode 100644
index 0000000000..ffc45384ea
--- /dev/null
+++ b/src/components/Object3DView/Object3D.styl
@@ -0,0 +1,19 @@
+.object3d
+ top 0
+ position absolute
+ overflow hidden
+
+.object3d-progress
+ padding 14px 16px
+ display flex
+ justify-content center
+ background #fff
+ border-radius 4px
+ box-shadow 0 0 0 0.5px rgba(0,0,0,0.2)
+ margin 16px 0.5px
+ flex-direction column
+ color #777
+ font-weight bold
+
+ &__bar
+ width 200px
diff --git a/src/components/Object3DView/Object3DView.js b/src/components/Object3DView/Object3DView.js
new file mode 100644
index 0000000000..cd4c1c05cb
--- /dev/null
+++ b/src/components/Object3DView/Object3DView.js
@@ -0,0 +1,197 @@
+import React from 'react';
+// import React, {
+// Component,
+// forwardRef,
+// useMemo,
+// useRef,
+// useState
+// } from 'react';
+import { observer } from 'mobx-react';
+// import * as THREE from 'three';
+// import { Color } from 'three';
+// import { Canvas } from '@react-three/fiber';
+// import { Center, Environment, Gltf, MapControls, OrbitControls, PivotControls, RandomizedLight, View } from '@react-three/drei';
+// import { AccumulativeShadows, OrthographicCamera, PerspectiveCamera } from '@react-three/drei';
+// import { Button, Menu } from '@mantine/core';
+// import * as ICONS from '@tabler/icons';
+// import useRefs from 'react-use-refs';
+// import create from 'zustand';
+
+
+// const matrix = new THREE.Matrix4();
+// const positions = { Top: [0, 10, 0], Bottom: [0, -10, 0], Left: [-10, 0, 0], Right: [10, 0, 0], Back: [0, 0, -10], Front: [0, 0, 10] };
+// const useStore = create((set) => ({
+// projection: 'Perspective',
+// top: 'Back',
+// middle: 'Top',
+// bottom: 'Right',
+// setPanelView: (which, view) => set({ [which]: view }),
+// setProjection: (projection) => set({ projection }),
+// }));
+
+const Object3DView = () => {
+ return (
+
+ );
+ // // stored position of canvas before creating region
+
+ // const [view1, view2, view3, view4] = useRefs();
+
+ // return (
+ //
+ //
+ // {/** Tracking div's, regular HTML and made responsive with CSS media-queries ... */}
+ //
+ //
+ //
+ //
+ //
+ // );
+ // };
+
+ // function CameraSwitcher() {
+ // const projection = useStore((state) => state.projection);
+
+ // // Would need to remember the old coordinates to be more useful ...
+ // return projection === 'Perspective' ? (
+ //
+ // ) : (
+ //
+ // );
+ // }
+
+ // function PanelCamera({ which }) {
+ // const view = useStore((state) => state[which]);
+
+ // return ;
+ // }
+
+ // const MainPanel = forwardRef((props, fref) => {
+ // const projection = useStore((state) => state.projection);
+ // const setProjection = useStore((state) => state.setProjection);
+
+ // return (
+ //
+ //
+ //
+ // );
+ // });
+
+ // const SidePanel = forwardRef(({ which }, fref) => {
+ // const value = useStore((state) => state[which]);
+ // const setPanelView = useStore((state) => state.setPanelView);
+
+ // return (
+ //
+ //
+ //
+ // );
+ // });
+
+
+ // function Scene({ background = 'white', children, ...props }) {
+ // return (
+ // <>
+ //
+ //
+ //
+ // {/* */}
+ // (self.matrix = matrix)}
+ // {...props}>
+ //
+ //
+
+ // {/* */}
+ //
+ // {children}
+ //
+ // >
+ // );
+ // }
+
+ // function Box({ text, ...props }) {
+ // const ref = useRef();
+ // const black = useMemo(() => new Color('black'), []);
+ // const lime = useMemo(() => new Color('lime'), []);
+ // const [hovered, setHovered] = useState(false);
+ // const transform = useRef();
+
+ // return (
+ // <>
+ // setHovered(true)}
+ // onPointerOut={() => setHovered(false)}
+ // {...props}
+ // ref={ref}
+ // >
+ //
+ //
+ // {props.children}
+ //
+ // setHovered(true)}
+ // onPointerOut={() => setHovered(false)}
+ // >
+ //
+ //
+ // {props.children}
+ //
+ // >
+ // //
+ // );
+};
+
+export default observer(Object3DView);
diff --git a/src/components/Object3DView/Object3DView.module.scss b/src/components/Object3DView/Object3DView.module.scss
new file mode 100644
index 0000000000..24e3105be2
--- /dev/null
+++ b/src/components/Object3DView/Object3DView.module.scss
@@ -0,0 +1,159 @@
+.block {
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ border: 1px solid rgba(34, 36, 38, 0.15);
+ border-radius: 0.28571429rem;
+ width: fit-content;
+ padding: 0.5em;
+}
+
+.block:empty {
+ display: none;
+}
+
+.divider {
+ margin: 12px 0;
+}
+
+.button {
+ margin: 0.3rem 0;
+}
+
+.wrapperComponent {
+ flex: 1;
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ align-self: stretch;
+}
+
+.wrapper {
+ max-width: 100%;
+ width: 100%;
+}
+
+.loading {
+ z-index: 10;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: auto;
+ height: auto;
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: center;
+ align-items: center;
+ background: rgba(125,125,125,.15);
+ font-size: 24px;
+}
+
+.image-element {
+ position: absolute;
+}
+
+.image_position {
+ position: absolute;
+ &__top {
+ top: 0;
+ }
+ &__middle {
+ top: 50%;
+ transform: translateY(-50%);
+ &.image_position__center {
+ transform: translate(-50%, -50%);
+ }
+ }
+ &__bottom {
+ bottom: 0;
+ }
+ &__left {
+ left: 0;
+ }
+ &__center {
+ left: 50%;
+ transform: translateX(-50%);
+ }
+ &__right {
+ right: 0;
+ }
+}
+
+.container {
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+ height: 100%;
+
+ // padding hack to fill the empty space with given aspect ratio
+ // see Object3DView.render()
+ .frame {
+ position: absolute;
+ overflow: hidden;
+ height: 0;
+ }
+ .frame_height {
+ width: 100%;
+ }
+ .filler {
+ max-width: 100%;
+ position: relative;
+ overflow: hidden;
+ }
+ .overlay {
+ position: absolute;
+ top: 0;
+ pointer-events: none;
+ z-index: 100;
+ }
+
+ img {
+ position: absolute;
+ top: 0;
+ }
+}
+
+.withGallery {
+ margin-bottom: 80px;
+}
+
+.withPagination {
+ padding-top: 30px; // pagination height 24px + 6px offset
+}
+
+.gallery {
+ position: absolute;
+ bottom: -80px;
+ display: flex;
+ overflow-x: auto;
+ width: 100%;
+ padding-bottom: 8px; // for scroll
+
+ img {
+ cursor: pointer;
+ margin-right: 4px;
+ border: 4px solid transparent;
+ max-width: 120px;
+ height: 60px;
+ object-fit: cover;
+
+ &:hover {
+ border-color: #1890ff66;
+ }
+
+ &.active {
+ border-color: #1890ff;
+ }
+ }
+}
+
+.pagination {
+ position: absolute;
+ top: 0;
+ display: flex;
+ width: 100%;
+ padding-right: 40px; // for toolbar
+}
diff --git a/src/components/Object3DView/Object3DViewContext.ts b/src/components/Object3DView/Object3DViewContext.ts
new file mode 100644
index 0000000000..9256b46b38
--- /dev/null
+++ b/src/components/Object3DView/Object3DViewContext.ts
@@ -0,0 +1,5 @@
+import { createContext } from 'react';
+
+export const Object3DViewContext = createContext<{suggestion: boolean}>({ suggestion: false });
+
+export const Object3DViewProvider = Object3DViewContext.Provider;
diff --git a/src/mixins/CubeDrawingTool.js b/src/mixins/CubeDrawingTool.js
new file mode 100644
index 0000000000..b8f2ce0478
--- /dev/null
+++ b/src/mixins/CubeDrawingTool.js
@@ -0,0 +1,605 @@
+import { types } from 'mobx-state-tree';
+
+import Utils from '../utils';
+import throttle from 'lodash.throttle';
+import { MIN_SIZE } from '../tools/Base';
+import { FF_DEV_3666, FF_DEV_3793, isFF } from '../utils/feature-flags';
+import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH } from '../components/Object3DView/Object3D';
+
+const DrawingTool = types
+ .model('DrawingTool', {
+ default: true,
+ mode: types.optional(types.enumeration(['drawing', 'viewing']), 'viewing'),
+ unselectRegionOnToolChange: true,
+ })
+ .volatile(() => {
+ return {
+ currentArea: null,
+ };
+ })
+ .views(self => {
+ return {
+ createRegionOptions(opts) {
+ return {
+ ...opts,
+ coordstype: 'px',
+ };
+ },
+ get tagTypes() {
+ console.error('Drawing tool model needs to implement tagTypes getter in views');
+ return {};
+ },
+ isIncorrectControl() {
+ return self.tagTypes.stateTypes === self.control.type && !self.control.isSelected;
+ },
+ isIncorrectLabel() {
+ return !self.obj.checkLabels();
+ },
+ get isDrawing() {
+ return self.mode === 'drawing';
+ },
+ get getActiveShape() {
+ return self.currentArea;
+ },
+ getCurrentArea() {
+ return self.currentArea;
+ },
+ current() {
+ return self.currentArea;
+ },
+ canStart() {
+ return !self.isDrawing && !self.annotation.isReadOnly();
+ },
+ get defaultDimensions() {
+ console.warn('Drawing tool model needs to implement defaultDimentions getter in views');
+ return {};
+ },
+ get MIN_SIZE() {
+ if (isFF(FF_DEV_3793)) {
+ return {
+ X: MIN_SIZE.X / self.obj.stageScale / self.obj.stageWidth * RELATIVE_STAGE_WIDTH,
+ Y: MIN_SIZE.Y / self.obj.stageScale / self.obj.stageHeight * RELATIVE_STAGE_HEIGHT,
+ };
+ }
+
+ return {
+ X: MIN_SIZE.X / self.obj.stageScale,
+ Y: MIN_SIZE.Y / self.obj.stageScale,
+ };
+ },
+ };
+ })
+ .actions(self => {
+ let lastClick = {
+ ts: 0,
+ x: 0,
+ y: 0,
+ };
+
+ return {
+ event(name, ev, [x, y, canvasX, canvasY]) {
+ // filter right clicks and middle clicks and shift pressed
+ if (ev.button > 0 || ev.shiftKey) return;
+ let fn = name + 'Ev';
+
+ if (typeof self[fn] !== 'undefined') self[fn].call(self, ev, [x, y], [canvasX, canvasY]);
+
+ // Emulating of dblclick event, 'cause redrawing will crush the the original one
+ if (name === 'click') {
+ const ts = ev.timeStamp;
+
+ if (ts - lastClick.ts < 300 && self.comparePointsWithThreshold(lastClick, { x, y })) {
+ fn = 'dbl' + fn;
+ if (typeof self[fn] !== 'undefined') self[fn].call(self, ev, [x, y], [canvasX, canvasY]);
+ }
+ lastClick = { ts, x, y };
+ }
+ },
+
+ comparePointsWithThreshold(p1, p2, threshold = { x: self.MIN_SIZE.X, y: self.MIN_SIZE.Y }) {
+ if (!p1 || !p2) return;
+ if (typeof threshold === 'number') threshold = { x: threshold, y: threshold };
+ return Math.abs(p1.x - p2.x) < threshold.x && Math.abs(p1.y - p2.y) < threshold.y;
+ },
+ };
+ })
+ .actions(self => {
+ return {
+ createDrawingRegion(opts) {
+ const control = self.control;
+ const resultValue = control.getResultValue();
+
+ self.currentArea = self.obj.createDrawingRegion(opts, resultValue, control, false);
+ self.currentArea.setDrawing(true);
+
+ self.applyActiveStates(self.currentArea);
+ self.annotation.setIsDrawing(true);
+ return self.currentArea;
+ },
+ resumeUnfinishedRegion(existingUnclosedPolygon) {
+ self.currentArea = existingUnclosedPolygon;
+ self.currentArea.setDrawing(true);
+ self.annotation.regionStore.selection._updateResultsFromRegions([self.currentArea]);
+ self.mode = 'drawing';
+ self.annotation.setIsDrawing(true);
+ self.annotation.regionStore.selection.drawingSelect(self.currentArea);
+ self.listenForClose?.();
+ },
+ commitDrawingRegion() {
+ const { currentArea, control, obj } = self;
+
+ if (!currentArea) return;
+ const source = currentArea.toJSON();
+ const value = Object.keys(currentArea.serialize().value).reduce((value, key) => {
+ value[key] = source[key];
+ return value;
+ }, { coordstype: 'px', dynamic: self.dynamic });
+
+ const [main, ...rest] = currentArea.results;
+ const newArea = self.annotation.createResult(value, main.value.toJSON(), control, obj);
+
+ //when user is using two different labels tag to draw a region, the other labels will be added to the region
+ rest.forEach(r => newArea.addResult(r.toJSON()));
+
+ currentArea.setDrawing(false);
+ self.deleteRegion();
+ newArea.notifyDrawingFinished();
+ return newArea;
+ },
+ createRegion(opts, skipAfterCreate = false) {
+ const control = self.control;
+ const resultValue = control.getResultValue();
+
+ self.currentArea = self.annotation.createResult(opts, resultValue, control, self.obj, skipAfterCreate);
+ self.applyActiveStates(self.currentArea);
+ return self.currentArea;
+ },
+ deleteRegion() {
+ self.currentArea = null;
+ self.obj.deleteDrawingRegion();
+ },
+ applyActiveStates(area) {
+ const activeStates = self.obj.activeStates();
+
+ activeStates.forEach(state => {
+ area.setValue(state);
+ });
+ },
+
+ beforeCommitDrawing() {
+ return true;
+ },
+
+ canStartDrawing() {
+ return !self.isIncorrectControl()
+ && (!isFF(FF_DEV_3666) || !self.isIncorrectLabel())
+ && self.canStart()
+ && !self.annotation.isDrawing;
+ },
+
+ startDrawing(x, y) {
+ self.annotation.history.freeze();
+ self.mode = 'drawing';
+ self.currentArea = self.createDrawingRegion(self.createRegionOptions({ x, y }));
+ },
+ finishDrawing() {
+ if (!self.beforeCommitDrawing()) {
+ self.deleteRegion();
+ if (self.control.type === self.tagTypes.stateTypes) self.annotation.unselectAll(true);
+ self._resetState();
+ } else {
+ self._finishDrawing();
+ }
+ },
+ _finishDrawing() {
+ self.commitDrawingRegion();
+ self._resetState();
+ },
+ _resetState() {
+ self.annotation.setIsDrawing(false);
+ self.annotation.history.unfreeze();
+ self.mode = 'viewing';
+ },
+ };
+ });
+
+const TwoPointsDrawingTool = DrawingTool.named('TwoPointsDrawingTool')
+ .views(self => ({
+ get defaultDimensions() {
+ return {
+ width: self.MIN_SIZE.X,
+ height: self.MIN_SIZE.Y,
+ };
+ },
+ }))
+ .actions(self => {
+ const DEFAULT_MODE = 0;
+ const DRAG_MODE = 1;
+ const TWO_CLICKS_MODE = 2;
+ let currentMode = DEFAULT_MODE;
+ let modeAfterMouseMove = DEFAULT_MODE;
+ let startPoint = null;
+ let endPoint = { x: 0, y: 0 };
+ const Super = {
+ finishDrawing: self.finishDrawing,
+ };
+
+ return {
+ updateDraw: throttle(function(x, y) {
+ if (currentMode === DEFAULT_MODE) return;
+ self.draw(x, y);
+ }, 48), // 3 frames, optimized enough and not laggy yet
+
+ draw(x, y) {
+ const shape = self.getCurrentArea();
+
+ if (!shape) return;
+ const isEllipse = shape.type.includes('ellipse');
+ const maxStageWidth = isFF(FF_DEV_3793) ? RELATIVE_STAGE_WIDTH : self.obj.stageWidth;
+ const maxStageHeight = isFF(FF_DEV_3793) ? RELATIVE_STAGE_HEIGHT : self.obj.stageHeight;
+
+ let { x1, y1, x2, y2 } = isEllipse ? {
+ x1: shape.startX,
+ y1: shape.startY,
+ x2: x,
+ y2: y,
+ } : Utils.Object3D.reverseCoordinates({ x: shape.startX, y: shape.startY }, { x, y });
+
+ x1 = Math.max(0, x1);
+ y1 = Math.max(0, y1);
+ x2 = Math.min(maxStageWidth, x2);
+ y2 = Math.min(maxStageHeight, y2);
+
+ let [distX, distY] = [x2 - x1, y2 - y1].map(Math.abs);
+
+ if (isEllipse) {
+ distX = Math.min(distX, Math.min(x1, maxStageWidth - x1));
+ distY = Math.min(distY, Math.min(y1, maxStageHeight - y1));
+ }
+
+ shape.setPositionInternal(x1, y1, distX, distY, shape.rotation);
+ },
+
+ finishDrawing(x, y) {
+ startPoint = null;
+ Super.finishDrawing(x, y);
+ currentMode = DEFAULT_MODE;
+ modeAfterMouseMove = DEFAULT_MODE;
+ },
+
+ mousedownEv(_, [x, y]) {
+ if (!self.canStartDrawing()) return;
+ startPoint = { x, y };
+ if (currentMode === DEFAULT_MODE) {
+ modeAfterMouseMove = DRAG_MODE;
+ }
+ },
+
+ mousemoveEv(_, [x, y]) {
+ if (currentMode === DEFAULT_MODE && startPoint) {
+ if (!self.comparePointsWithThreshold(startPoint, { x, y })) {
+ currentMode = modeAfterMouseMove;
+ if ([DRAG_MODE, TWO_CLICKS_MODE].includes(currentMode)) {
+ self.startDrawing(startPoint.x, startPoint.y);
+ if (!self.isDrawing) {
+ currentMode = DEFAULT_MODE;
+ return;
+ }
+ }
+ }
+ }
+ if (!self.isDrawing) return;
+ if ([DRAG_MODE, TWO_CLICKS_MODE].includes(currentMode)) {
+ self.updateDraw(x, y);
+ }
+ },
+
+ mouseupEv(_, [x, y]) {
+ if (currentMode !== DRAG_MODE) return;
+ endPoint = { x, y };
+ if (!self.isDrawing) return;
+ self.draw(x, y);
+ self.finishDrawing(x, y);
+ },
+
+ clickEv(_, [x, y]) {
+ if (!self.canStartDrawing()) return;
+ // @todo: here is a potential problem with endPoint
+ // it may be incorrect due to it may be not set at this moment
+ if (startPoint && endPoint && !self.comparePointsWithThreshold(startPoint, endPoint)) return;
+ if (currentMode === DEFAULT_MODE) {
+ modeAfterMouseMove = TWO_CLICKS_MODE;
+ } else if (self.isDrawing && currentMode === TWO_CLICKS_MODE) {
+ self.draw(x, y);
+ self.finishDrawing(x, y);
+ currentMode = DEFAULT_MODE;
+ }
+ },
+
+ dblclickEv(_, [x, y]) {
+ if (!self.canStartDrawing()) return;
+
+ let dX = self.defaultDimensions.width;
+ let dY = self.defaultDimensions.height;
+
+ if (isFF(FF_DEV_3793)) {
+ dX = self.obj.canvasToInternalX(dX);
+ dY = self.obj.canvasToInternalY(dY);
+ }
+
+ if (currentMode === DEFAULT_MODE) {
+ self.startDrawing(x, y);
+ if (!self.isDrawing) return;
+ x += dX;
+ y += dY;
+ self.draw(x, y);
+ self.finishDrawing(x, y);
+ }
+ },
+ };
+ });
+
+const MultipleClicksDrawingTool = DrawingTool.named('MultipleClicksMixin')
+ .views(() => ({
+ canStart() {
+ return !this.current();
+ },
+ }))
+ .actions(self => {
+ let startPoint = { x: 0, y: 0 };
+ let pointsCount = 0;
+ let lastPoint = { x: -1, y: -1 };
+ let lastEvent = 0;
+ const MOUSE_DOWN_EVENT = 1;
+ const MOUSE_UP_EVENT = 2;
+ const CLICK_EVENT = 3;
+ let lastClickTs = 0;
+ const Super = {
+ canStartDrawing: self.canStartDrawing,
+ };
+
+ return {
+ canStartDrawing() {
+ return Super.canStartDrawing() && !self.annotation.regionStore.hasSelection;
+ },
+ nextPoint(x, y) {
+ const area = self.getCurrentArea();
+ const object = self.obj;
+
+ if (area && object && object.multiObject3D && area.item_index !== object.currentObject3D) return;
+
+ self.getCurrentArea().addPoint(x, y);
+ pointsCount++;
+ },
+ listenForClose() {
+ console.error('MultipleClicksMixin model needs to implement listenForClose method in actions');
+ },
+ closeCurrent() {
+ console.error('MultipleClicksMixin model needs to implement closeCurrent method in actions');
+ },
+ finishDrawing() {
+ if (!self.isDrawing) return;
+
+ self.annotation.regionStore.selection.drawingUnselect();
+
+ pointsCount = 0;
+ self.closeCurrent();
+ setTimeout(() => {
+ self._finishDrawing();
+ });
+ },
+ cleanupUncloseableShape() {
+ self.deleteRegion();
+ if (self.control.type === self.tagTypes.stateTypes) self.annotation.unselectAll(true);
+ self._resetState();
+ },
+ mousedownEv(ev, [x, y]) {
+ lastPoint = { x, y };
+ lastEvent = MOUSE_DOWN_EVENT;
+ },
+ mouseupEv(ev, [x, y]) {
+ if (lastEvent === MOUSE_DOWN_EVENT && self.comparePointsWithThreshold(lastPoint, { x, y })) {
+ self._clickEv(ev, [x, y]);
+ lastEvent = MOUSE_UP_EVENT;
+ }
+ lastPoint = { x: -1, y: -1 };
+ },
+ clickEv(ev, [x, y]) {
+ if (lastEvent !== MOUSE_UP_EVENT) {
+ self._clickEv(ev, [x, y]);
+ }
+ lastEvent = CLICK_EVENT;
+ lastPoint = { x: -1, y: -1 };
+ },
+ _clickEv(ev, [x, y]) {
+ if (self.current()) {
+ if (
+ pointsCount === 1 &&
+ self.comparePointsWithThreshold(startPoint, { x, y }) &&
+ ev.timeStamp - lastClickTs < 350
+ ) {
+ // dblclick
+ self.drawDefault();
+ } else {
+ if (self.comparePointsWithThreshold(startPoint, { x, y })) {
+ if (pointsCount > 2) {
+ self.finishDrawing();
+ }
+ } else {
+ self.nextPoint(x, y);
+ }
+ }
+ } else {
+ if (!self.canStartDrawing()) return;
+ startPoint = { x, y };
+ pointsCount = 1;
+ lastClickTs = ev.timeStamp;
+ self.startDrawing(x, y);
+ self.listenForClose();
+ }
+ },
+
+ drawDefault() {
+ const { x, y } = startPoint;
+ let dX = self.defaultDimensions.length;
+ let dY = self.defaultDimensions.length;
+
+ if (isFF(FF_DEV_3793)) {
+ dX = self.obj.canvasToInternalX(dX);
+ dY = self.obj.canvasToInternalY(dY);
+ }
+
+ self.nextPoint(x + dX, y);
+ self.nextPoint(
+ x + dX / 2,
+ y + Math.sin(Math.PI / 3) * dY,
+ );
+ self.finishDrawing();
+ },
+ };
+ });
+
+const ThreePointsDrawingTool = DrawingTool.named('ThreePointsDrawingTool')
+ .views((self) => ({
+ canStart() {
+ return !this.current();
+ },
+ get defaultDimensions() {
+ return {
+ width: self.MIN_SIZE.X,
+ height: self.MIN_SIZE.Y,
+ };
+ },
+ }))
+ .actions(self => {
+ let points = [];
+ let lastEvent = 0;
+ const DEFAULT_MODE = 0;
+ const MOUSE_DOWN_EVENT = 1;
+ const MOUSE_UP_EVENT = 2;
+ const CLICK_EVENT = 3;
+ const DRAG_MODE = 4;
+ const DBL_CLICK_EVENT = 5;
+ let currentMode = DEFAULT_MODE;
+ let startPoint = null;
+ const Super = {
+ finishDrawing: self.finishDrawing,
+ };
+
+ return {
+ canStartDrawing() {
+ return !self.isIncorrectControl();
+ },
+ updateDraw: (x, y) => {
+ if (currentMode === DEFAULT_MODE)
+ self.getCurrentArea()?.draw(x, y, points);
+ else if (currentMode === DRAG_MODE)
+ self.draw(x, y);
+ },
+
+ nextPoint(x, y) {
+ points.push({ x, y });
+ self.getCurrentArea().draw(x, y, points);
+ },
+ draw(x, y) {
+ const shape = self.getCurrentArea();
+
+ if (!shape) return;
+ const maxStageWidth = isFF(FF_DEV_3793) ? RELATIVE_STAGE_WIDTH : self.obj.stageWidth;
+ const maxStageHeight = isFF(FF_DEV_3793) ? RELATIVE_STAGE_HEIGHT : self.obj.stageHeight;
+
+ let { x1, y1, x2, y2 } = Utils.Object3D.reverseCoordinates({ x: shape.startX, y: shape.startY }, { x, y });
+
+ x1 = Math.max(0, x1);
+ y1 = Math.max(0, y1);
+ x2 = Math.min(maxStageWidth, x2);
+ y2 = Math.min(maxStageHeight, y2);
+
+ shape.setPositionInternal(x1, y1, x2 - x1, y2 - y1, shape.rotation);
+ },
+
+ finishDrawing(x, y) {
+ if (self.isDrawing) {
+ points = [];
+ startPoint = null;
+ currentMode = DEFAULT_MODE;
+ Super.finishDrawing(x, y);
+ setTimeout(() => {
+ self._finishDrawing();
+ });
+ } else return;
+ },
+
+ mousemoveEv(_, [x, y]) {
+ if (self.isDrawing) {
+ if (lastEvent === MOUSE_DOWN_EVENT) {
+ currentMode = DRAG_MODE;
+ }
+
+ if (currentMode === DRAG_MODE && startPoint) {
+ self.startDrawing(startPoint.x, startPoint.y);
+ self.updateDraw(x, y);
+ } else if (currentMode === DEFAULT_MODE) {
+ self.updateDraw(x, y);
+ }
+ }
+ },
+ mousedownEv(ev, [x, y]) {
+ if (!self.canStartDrawing() || self.annotation.isDrawing) return;
+ lastEvent = MOUSE_DOWN_EVENT;
+ startPoint = { x, y };
+ self.mode = 'drawing';
+ },
+ mouseupEv(ev, [x, y]) {
+ if (!self.canStartDrawing()) return;
+ if (self.isDrawing) {
+ if (currentMode === DRAG_MODE) {
+ self.draw(x, y);
+ self.finishDrawing(x, y);
+ }
+ lastEvent = MOUSE_UP_EVENT;
+ }
+ },
+ clickEv(ev, [x, y]) {
+ if (!self.canStartDrawing()) return;
+ if (currentMode === DEFAULT_MODE) {
+ self._clickEv(ev, [x, y]);
+ }
+ lastEvent = CLICK_EVENT;
+ },
+ _clickEv(ev, [x, y]) {
+ if (points.length >= 2) {
+ self.finishDrawing(x, y);
+ } else if (points.length === 0) {
+ points = [{ x, y }];
+ self.startDrawing(x, y);
+ } else {
+ self.nextPoint(x, y);
+ }
+ },
+
+ dblclickEv(_, [x, y]) {
+ lastEvent = DBL_CLICK_EVENT;
+ if (!self.canStartDrawing()) return;
+
+ let dX = self.defaultDimensions.width;
+ let dY = self.defaultDimensions.height;
+
+ if (isFF(FF_DEV_3793)) {
+ dX = self.obj.canvasToInternalX(dX);
+ dY = self.obj.canvasToInternalY(dY);
+ }
+
+ if (currentMode === DEFAULT_MODE) {
+ self.startDrawing(x, y);
+ if (!self.isDrawing) return;
+ x += dX;
+ y += dY;
+ self.draw(x, y);
+ self.finishDrawing(x, y);
+ }
+ },
+ };
+ });
+
+export { DrawingTool, TwoPointsDrawingTool, MultipleClicksDrawingTool };
diff --git a/src/regions/CubeRegion.js b/src/regions/CubeRegion.js
new file mode 100644
index 0000000000..fa97ff3239
--- /dev/null
+++ b/src/regions/CubeRegion.js
@@ -0,0 +1,421 @@
+import { getRoot, isAlive, types } from 'mobx-state-tree';
+import React, { useContext } from 'react';
+import { Rect } from 'react-konva';
+import { Object3DViewContext } from '../components/Object3DView/Object3DViewContext';
+import { LabelOnRect } from '../components/Object3DView/LabelOnRegion';
+import Constants from '../core/Constants';
+import { guidGenerator } from '../core/Helpers';
+import Registry from '../core/Registry';
+import { useRegionStyles } from '../hooks/useRegionColor';
+import { AreaMixin } from '../mixins/AreaMixin';
+import { KonvaRegionMixin } from '../mixins/KonvaRegion';
+import NormalizationMixin from '../mixins/Normalization';
+import RegionsMixin from '../mixins/Regions';
+import { Object3DModel } from '../tags/object/Object3D';
+import { rotateBboxCoords } from '../utils/bboxCoords';
+import { FF_DEV_3793, isFF } from '../utils/feature-flags';
+import { createDragBoundFunc } from '../utils/image';
+import { AliveRegion } from './AliveRegion';
+import { EditableRegion } from './EditableRegion';
+import { RegionWrapper } from './RegionWrapper';
+import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH } from '../components/Object3DView/Object3D';
+
+/**
+ * Rectangle object for Bounding Box
+ *
+ */
+const Model = types
+ .model({
+ id: types.optional(types.identifier, guidGenerator),
+ pid: types.optional(types.string, guidGenerator),
+ type: 'cuberegion',
+ object: types.late(() => types.reference(Object3DModel)),
+
+ x: types.number,
+ y: types.number,
+
+ width: types.number,
+ height: types.number,
+
+ rotation: 0,
+ rotationAtCreation: 0,
+ })
+ .volatile(() => ({
+ startX: 0,
+ startY: 0,
+
+ // @todo not used
+ scaleX: 1,
+ scaleY: 1,
+
+ opacity: 1,
+
+ fill: true,
+ fillColor: '#ff8800', // Constants.FILL_COLOR,
+ fillOpacity: 0.2,
+
+ strokeColor: Constants.STROKE_COLOR,
+ strokeWidth: Constants.STROKE_WIDTH,
+
+ _supportsTransform: true,
+ // depends on region and object tag; they both should correctly handle the `hidden` flag
+ hideable: true,
+
+ editableFields: [
+ { property: 'x', label: 'X' },
+ { property: 'y', label: 'Y' },
+ { property: 'width', label: 'W' },
+ { property: 'height', label: 'H' },
+ { property: 'rotation', label: 'icon:angle' },
+ ],
+ }))
+ .volatile(() => {
+ return {
+ useTransformer: true,
+ preferTransformer: true,
+ supportsRotate: true,
+ supportsScale: true,
+ };
+ })
+ .views(self => ({
+ get store() {
+ return getRoot(self);
+ },
+ get parent() {
+ return isAlive(self) ? self.object : null;
+ },
+ get bboxCoords() {
+ const bboxCoords = {
+ left: self.x,
+ top: self.y,
+ right: self.x + self.width,
+ bottom: self.y + self.height,
+ };
+
+ if (self.rotation === 0 || !self.parent) return bboxCoords;
+
+ return rotateBboxCoords(bboxCoords, self.rotation, { x: self.x, y: self.y }, self.parent.whRatio);
+ },
+ get canvasX() {
+ return isFF(FF_DEV_3793) ? self.parent?.internalToCanvasX(self.x) : self.x;
+ },
+ get canvasY() {
+ return isFF(FF_DEV_3793) ? self.parent?.internalToCanvasY(self.y) : self.y;
+ },
+ get canvasWidth() {
+ return isFF(FF_DEV_3793) ? self.parent?.internalToCanvasX(self.width) : self.width;
+ },
+ get canvasHeight() {
+ return isFF(FF_DEV_3793) ? self.parent?.internalToCanvasY(self.height) : self.height;
+ },
+ }))
+ .actions(self => ({
+ afterCreate() {
+ self.startX = self.x;
+ self.startY = self.y;
+ },
+
+ getDistanceBetweenPoints(pointA, pointB) {
+ const { x: xA, y: yA } = pointA;
+ const { x: xB, y: yB } = pointB;
+ const distanceX = xA - xB;
+ const distanceY = yA - yB;
+
+ return Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2));
+ },
+
+ getHeightOnPerpendicular(pointA, pointB, cursor) {
+ const dX = pointB.x - pointA.x;
+ const dY = pointB.y - pointA.y;
+ const s2 = Math.abs(dY * cursor.x - dX * cursor.y + pointB.x * pointA.y - pointB.y * pointA.x);
+ const ab = Math.sqrt(dY * dY + dX * dX);
+
+ return s2 / ab;
+ },
+
+ isAboveTheLine(a, b, c) {
+ return ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) < 0;
+ },
+
+ draw(x, y, points) {
+ const oldHeight = self.height;
+ const canvasX = self.parent.internalToCanvasX(x);
+ const canvasY = self.parent.internalToCanvasY(y);
+
+ if (points.length === 1) {
+ const canvasWidth = self.getDistanceBetweenPoints({ x: canvasX, y: canvasY }, {
+ x: self.canvasX,
+ y: self.canvasY,
+ });
+
+ self.width = self.parent.canvasToInternalX(canvasWidth);
+ self.rotation = self.rotationAtCreation = Math.atan2(canvasY - self.canvasY, canvasX - self.canvasX) * (180 / Math.PI);
+ } else if (points.length === 2) {
+ const canvasPoints = points.map(({ x, y }) => ({
+ x: self.parent.internalToCanvasX(x),
+ y: self.parent.internalToCanvasY(y),
+ }));
+ const { y: firstPointY, x: firstPointX } = points[0];
+ const { y: secondPointY, x: secondPointX } = points[1];
+
+ if (self.isAboveTheLine(canvasPoints[0], canvasPoints[1], { x: canvasX, y: canvasY })) {
+ self.x = secondPointX;
+ self.y = secondPointY;
+ self.rotation = self.rotationAtCreation + 180;
+ } else {
+ self.x = firstPointX;
+ self.y = firstPointY;
+ self.rotation = self.rotationAtCreation;
+ }
+ const canvasHeight = self.getHeightOnPerpendicular(canvasPoints[0], canvasPoints[1], {
+ x: canvasX,
+ y: canvasY,
+ });
+
+ self.height = self.parent.canvasToInternalY(canvasHeight);
+ }
+ self.setPositionInternal(self.x, self.y, self.width, self.height, self.rotation);
+
+ const areaBBoxCoords = self?.bboxCoords;
+
+ if (
+ areaBBoxCoords?.left < 0 ||
+ areaBBoxCoords?.top < 0 ||
+ areaBBoxCoords?.right > RELATIVE_STAGE_WIDTH ||
+ areaBBoxCoords?.bottom > RELATIVE_STAGE_HEIGHT
+ ) {
+ self.height = oldHeight;
+ }
+ },
+
+ // @todo not used
+ coordsInside(x, y) {
+ // check if x and y are inside the rectangle
+ const rx = self.x;
+ const ry = self.y;
+ const rw = self.width * (self.scaleX || 1);
+ const rh = self.height * (self.scaleY || 1);
+
+ if (x > rx && x < rx + rw && y > ry && y < ry + rh) return true;
+
+ return false;
+ },
+
+ setPositionInternal(x, y, width, height, rotation) {
+ self.x = x;
+ self.y = y;
+ self.width = width;
+ self.height = height;
+ self.rotation = (rotation + 360) % 360;
+ },
+
+ /**
+ * Bounding Box set position on canvas
+ * @param {number} x
+ * @param {number} y
+ * @param {number} width
+ * @param {number} height
+ * @param {number} rotation
+ */
+ setPosition(x, y, width, height, rotation) {
+ self.setPositionInternal(
+ self.parent.canvasToInternalX(x),
+ self.parent.canvasToInternalY(y),
+ self.parent.canvasToInternalX(width),
+ self.parent.canvasToInternalY(height),
+ rotation,
+ );
+ },
+
+ setScale(x, y) {
+ self.scaleX = x;
+ self.scaleY = y;
+ },
+
+ addState(state) {
+ self.states.push(state);
+ },
+
+ setFill(color) {
+ self.fill = color;
+ },
+
+ updateObject3DSize() {},
+
+ /**
+ * @example
+ * {
+ * "original_width": 1920,
+ * "original_height": 1280,
+ * "image_rotation": 0,
+ * "value": {
+ * "x": 3.1,
+ * "y": 8.2,
+ * "width": 20,
+ * "height": 16,
+ * "rectanglelabels": ["Car"]
+ * }
+ * }
+ * @typedef {Object} RectRegionResult
+ * @property {number} original_width width of the original image (px)
+ * @property {number} original_height height of the original image (px)
+ * @property {number} image_rotation rotation degree of the image (deg)
+ * @property {Object} value
+ * @property {number} value.x x coordinate of the top left corner before rotation (0-100)
+ * @property {number} value.y y coordinate of the top left corner before rotation (0-100)
+ * @property {number} value.width width of the bounding box (0-100)
+ * @property {number} value.height height of the bounding box (0-100)
+ * @property {number} value.rotation rotation degree of the bounding box (deg)
+ */
+
+ /**
+ * @return {RectRegionResult}
+ */
+ serialize() {
+ const value = {
+ x: (self.parent.stageWidth > 1 && !isFF(FF_DEV_3793)) ? self.convertXToPerc(self.x) : self.x,
+ y: (self.parent.stageWidth > 1 && !isFF(FF_DEV_3793)) ? self.convertYToPerc(self.y) : self.y,
+ width: (self.parent.stageWidth > 1 && !isFF(FF_DEV_3793)) ? self.convertHDimensionToPerc(self.width) : self.width,
+ height: (self.parent.stageWidth > 1 && !isFF(FF_DEV_3793)) ? self.convertVDimensionToPerc(self.height) : self.height,
+ rotation: self.rotation,
+ };
+
+ return self.parent.createSerializedResult(self, value);
+ },
+ }));
+
+const CubeRegionModel = types.compose(
+ 'CubeRegionModel',
+ RegionsMixin,
+ NormalizationMixin,
+ AreaMixin,
+ KonvaRegionMixin, /* react-3-fiber */
+ EditableRegion,
+ Model,
+);
+
+const HtxCubeView = ({ item, setShapeRef }) => {
+ const { store } = item;
+
+ const { suggestion } = useContext(Object3DViewContext) ?? {};
+ const regionStyles = useRegionStyles(item, { suggestion });
+ const stage = item.parent?.stageRef;
+
+ const eventHandlers = {};
+
+ if (!item.parent) return null;
+ if (!item.inViewPort) return null;
+
+ if (!suggestion && !item.isReadOnly()) {
+ eventHandlers.onTransform = ({ target }) => {
+ // resetting the skew makes transformations weird but predictable
+ target.setAttr('skewX', 0);
+ target.setAttr('skewY', 0);
+ };
+ eventHandlers.onTransformEnd = (e) => {
+ const t = e.target;
+
+ item.setPosition(
+ t.getAttr('x'),
+ t.getAttr('y'),
+ t.getAttr('width') * t.getAttr('scaleX'),
+ t.getAttr('height') * t.getAttr('scaleY'),
+ t.getAttr('rotation'),
+ );
+
+ t.setAttr('scaleX', 1);
+ t.setAttr('scaleY', 1);
+
+ item.notifyDrawingFinished();
+ };
+
+ eventHandlers.onDragStart = (e) => {
+ if (item.parent.getSkipInteractions()) {
+ e.currentTarget.stopDrag(e.evt);
+ return;
+ }
+ item.annotation.history.freeze(item.id);
+ };
+
+ eventHandlers.onDragEnd = (e) => {
+ const t = e.target;
+
+ item.setPosition(
+ t.getAttr('x'),
+ t.getAttr('y'),
+ t.getAttr('width'),
+ t.getAttr('height'),
+ t.getAttr('rotation'),
+ );
+ item.setScale(t.getAttr('scaleX'), t.getAttr('scaleY'));
+ item.annotation.history.unfreeze(item.id);
+
+ item.notifyDrawingFinished();
+ };
+
+ eventHandlers.dragBoundFunc = createDragBoundFunc(item, {
+ x: item.x - item.bboxCoords.left,
+ y: item.y - item.bboxCoords.top,
+ });
+ }
+
+ return (
+
+ setShapeRef(node)}
+ width={item.canvasWidth}
+ height={item.canvasHeight}
+ fill={regionStyles.fillColor}
+ stroke={regionStyles.strokeColor}
+ strokeWidth={regionStyles.strokeWidth}
+ strokeScaleEnabled={false}
+ perfectDrawEnabled={false}
+ shadowForStrokeEnabled={false}
+ shadowBlur={0}
+ dash={suggestion ? [10, 10] : null}
+ scaleX={item.scaleX}
+ scaleY={item.scaleY}
+ opacity={1}
+ rotation={item.rotation}
+ draggable={!item.isReadOnly()}
+ name={`${item.id} _transformable`}
+ {...eventHandlers}
+ onMouseOver={() => {
+ if (store.annotationStore.selected.relationMode) {
+ item.setHighlight(true);
+ stage.container().style.cursor = Constants.RELATION_MODE_CURSOR;
+ } else {
+ stage.container().style.cursor = Constants.POINTER_CURSOR;
+ }
+ }}
+ onMouseOut={() => {
+ stage.container().style.cursor = Constants.DEFAULT_CURSOR;
+
+ if (store.annotationStore.selected.relationMode) {
+ item.setHighlight(false);
+ }
+ }}
+ onClick={e => {
+ if (item.parent.getSkipInteractions()) return;
+ if (store.annotationStore.selected.relationMode) {
+ stage.container().style.cursor = Constants.DEFAULT_CURSOR;
+ }
+
+ item.setHighlight(false);
+ item.onClickRegion(e);
+ }}
+ listening={!suggestion && !item.annotation?.isDrawing}
+ />
+
+
+ );
+};
+
+const HtxCube = AliveRegion(HtxCubeView);
+
+Registry.addTag('cuberegion', CubeRegionModel, HtxCube);
+Registry.addRegionType(CubeRegionModel, 'object3d');
+
+export { CubeRegionModel, HtxCube };
diff --git a/src/regions/Result.js b/src/regions/Result.js
index d1756fa6fd..b2803c8c1d 100644
--- a/src/regions/Result.js
+++ b/src/regions/Result.js
@@ -57,6 +57,7 @@ const Result = types
'pairwise',
'videorectangle',
'ranker',
+ 'cubelabels',
]),
// @todo much better to have just a value, not a hash with empty fields
value: types.model({
@@ -75,6 +76,7 @@ const Result = types
hypertextlabels: types.maybe(types.array(types.string)),
paragraphlabels: types.maybe(types.array(types.string)),
rectanglelabels: types.maybe(types.array(types.string)),
+ cubelabels: types.maybe(types.array(types.string)),
keypointlabels: types.maybe(types.array(types.string)),
polygonlabels: types.maybe(types.array(types.string)),
ellipselabels: types.maybe(types.array(types.string)),
diff --git a/src/tags/control/Cube.js b/src/tags/control/Cube.js
new file mode 100644
index 0000000000..7f60060ca8
--- /dev/null
+++ b/src/tags/control/Cube.js
@@ -0,0 +1,69 @@
+import { types } from 'mobx-state-tree';
+
+import Registry from '../../core/Registry';
+import ControlBase from './Base';
+import { customTypes } from '../../core/CustomTypes';
+import { AnnotationMixin } from '../../mixins/AnnotationMixin';
+import SeparatedControlMixin from '../../mixins/SeparatedControlMixin';
+import { ToolManagerMixin } from '../../mixins/ToolManagerMixin';
+
+/**
+ * The `Cube` tag is used to add a Cube (Bounding Box) to an image without selecting a label. This can be useful when you have only one label to assign to a Cube.
+ *
+ * Use with the following data types: image.
+ * @example
+ *
+ *
+ *
+ *
+ *
+ * @name Cube
+ * @meta_title Cube Tag for Adding Cube Bounding Box to Images
+ * @meta_description Customize Label Studio with the Cube tag to add Cube bounding boxes to images for machine learning and data science projects.
+ * @param {string} name - Name of the element
+ * @param {string} toName - Name of the image to label
+ * @param {float=} [opacity=0.6] - Opacity of Cube
+ * @param {string=} [fillColor] - Cube fill color in hexadecimal
+ * @param {string=} [strokeColor=#f48a42] - Stroke color in hexadecimal
+ * @param {number=} [strokeWidth=1] - Width of the stroke
+ * @param {boolean=} [canRotate=true] - Whether to show or hide rotation control
+ * @param {boolean} [smart] - Show smart tool for interactive pre-annotations
+ * @param {boolean} [smartOnly] - Only show smart tool for interactive pre-annotations
+ */
+const TagAttrs = types.model({
+ toname: types.maybeNull(types.string),
+
+ opacity: types.optional(customTypes.range(), '0.2'),
+ fillcolor: types.optional(customTypes.color, '#f48a42'),
+
+ strokewidth: types.optional(types.string, '1'),
+ strokecolor: types.optional(customTypes.color, '#f48a42'),
+ fillopacity: types.maybeNull(customTypes.range()),
+
+ canrotate: types.optional(types.boolean, true),
+});
+
+const Model = types
+ .model({
+ type: 'cube',
+ })
+ .volatile(() => ({
+ toolNames: ['Cube'],
+ }));
+
+const CubeModel = types.compose('CubeModel',
+ ControlBase,
+ AnnotationMixin,
+ SeparatedControlMixin,
+ TagAttrs,
+ Model,
+ ToolManagerMixin,
+);
+
+const HtxView = () => {
+ return null;
+};
+
+Registry.addTag('cube', CubeModel, HtxView);
+
+export { HtxView, CubeModel };
diff --git a/src/tags/control/CubeLabels.js b/src/tags/control/CubeLabels.js
new file mode 100644
index 0000000000..229f174c1c
--- /dev/null
+++ b/src/tags/control/CubeLabels.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { observer } from 'mobx-react';
+import { types } from 'mobx-state-tree';
+
+import LabelMixin from '../../mixins/LabelMixin';
+import Registry from '../../core/Registry';
+import SelectedModelMixin from '../../mixins/SelectedModel';
+import Types from '../../core/Types';
+import { HtxLabels, LabelsModel } from './Labels/Labels';
+import { CubeModel } from './Cube';
+import { guidGenerator } from '../../core/Helpers';
+import ControlBase from './Base';
+
+/**
+ * The `CubeLabels` tag creates labeled Cubes. Use to apply labels to bounding box semantic segmentation tasks.
+ *
+ * Use with the following data types: image.
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @name CubeLabels
+ * @regions RectRegion
+ * @meta_title Cube Label Tag to Label Cube Bounding Box in Images
+ * @meta_description Customize Label Studio with the CubeLabels tag and add labeled Cube bounding boxes in images for semantic segmentation and object detection machine learning and data science projects.
+ * @param {string} name - Name of the element
+ * @param {string} toName - Name of the image to label
+ * @param {single|multiple=} [choice=single] - Configure whether you can select one or multiple labels
+ * @param {number} [maxUsages] - Maximum number of times a label can be used per task
+ * @param {boolean} [showInline=true] - Show labels in the same visual line
+ * @param {float} [opacity=0.6] - Opacity of Cube
+ * @param {string} [fillColor] - Cube fill color in hexadecimal
+ * @param {string} [strokeColor] - Stroke color in hexadecimal
+ * @param {number} [strokeWidth=1] - Width of stroke
+ * @param {boolean} [canRotate=true] - Show or hide rotation control
+ */
+
+const Validation = types.model({
+ controlledTags: Types.unionTag(['Object3D']),
+});
+
+const ModelAttrs = types.model('CubeLabelsModel', {
+ pid: types.optional(types.string, guidGenerator),
+ type: 'cubelabels',
+ children: Types.unionArray(['label', 'header', 'view', 'hypertext']),
+});
+
+const Composition = types.compose(
+ ControlBase,
+ LabelsModel,
+ ModelAttrs,
+ CubeModel,
+ Validation,
+ LabelMixin,
+ SelectedModelMixin.props({ _child: 'LabelModel' }),
+);
+
+const CubeLabelsModel = types.compose('CubeLabelsModel', Composition);
+
+const HtxCubeLabels = observer(({ item }) => {
+ return ;
+});
+
+Registry.addTag('cubelabels', CubeLabelsModel, HtxCubeLabels);
+
+export { HtxCubeLabels, CubeLabelsModel };
diff --git a/src/tags/control/Label.js b/src/tags/control/Label.js
index 26e7fe72a0..1346d72dd0 100644
--- a/src/tags/control/Label.js
+++ b/src/tags/control/Label.js
@@ -73,6 +73,7 @@ const Model = types.model({
_value: types.optional(types.string, ''),
parentTypes: Types.tagsTypes([
'Labels',
+ 'CubeLabels',
'EllipseLabels',
'RectangleLabels',
'PolygonLabels',
diff --git a/src/tags/control/index.js b/src/tags/control/index.js
index 2405be71b6..e682995144 100644
--- a/src/tags/control/index.js
+++ b/src/tags/control/index.js
@@ -1,4 +1,6 @@
import { ChoicesModel } from './Choices';
+import { CubeModel } from './Cube';
+import { CubeLabelsModel } from './CubeLabels';
import { DateTimeModel } from './DateTime';
import { NumberModel } from './Number';
import { PairwiseModel } from './Pairwise';
@@ -31,6 +33,8 @@ import { RelationModel } from './Relation';
export {
ChoicesModel,
+ CubeModel,
+ CubeLabelsModel,
DateTimeModel,
NumberModel,
PairwiseModel,
diff --git a/src/tags/object/Image/Image.js b/src/tags/object/Image/Image.js
index 8baa558cee..37577c02e1 100644
--- a/src/tags/object/Image/Image.js
+++ b/src/tags/object/Image/Image.js
@@ -140,6 +140,9 @@ const IMAGE_CONSTANTS = {
brushlabels: 'brushlabels',
brushModel: 'BrushModel',
ellipselabels: 'ellipselabels',
+ cubeModel: 'CubeModel',
+ cubeLabelsModel: 'CubeLabelsModel',
+ cubelabels: 'cubelabels',
};
const Model = types.model({
@@ -404,7 +407,8 @@ const Model = types.model({
if (
item.type === IMAGE_CONSTANTS.rectanglelabels ||
item.type === IMAGE_CONSTANTS.brushlabels ||
- item.type === IMAGE_CONSTANTS.ellipselabels
+ item.type === IMAGE_CONSTANTS.ellipselabels ||
+ item.type === IMAGE_CONSTANTS.cubelabels
) {
returnedControl = item;
}
diff --git a/src/tags/object/Object3D/DrawingRegion.js b/src/tags/object/Object3D/DrawingRegion.js
new file mode 100644
index 0000000000..a563ff7049
--- /dev/null
+++ b/src/tags/object/Object3D/DrawingRegion.js
@@ -0,0 +1,19 @@
+import { types } from 'mobx-state-tree';
+import Registry from '../../../core/Registry';
+
+export const DrawingRegion = types.union(
+ {
+ dispatcher(sn) {
+ if (!sn) return types.null;
+ // may be a tag itself or just its name
+ const objectName = sn.object.name || sn.object;
+ // we have to use current config to detect Object tag by name
+ const tag = window.Htx.annotationStore.names.get(objectName);
+ // provide value to detect Area by data
+ const available = Registry.getAvailableAreas(tag.type, sn);
+ // union of all available Areas for this Object type
+
+ return types.union(...available, types.null);
+ },
+ },
+);
diff --git a/src/tags/object/Object3D/Object3D.js b/src/tags/object/Object3D/Object3D.js
new file mode 100644
index 0000000000..2ae7df1eb8
--- /dev/null
+++ b/src/tags/object/Object3D/Object3D.js
@@ -0,0 +1,1167 @@
+import { inject } from 'mobx-react';
+import { destroy, getRoot, getType, types } from 'mobx-state-tree';
+
+import Object3DView from '../../../components/Object3DView/Object3DView';
+import { customTypes } from '../../../core/CustomTypes';
+import Registry from '../../../core/Registry';
+import { AnnotationMixin } from '../../../mixins/AnnotationMixin';
+import { IsReadyWithDepsMixin } from '../../../mixins/IsReadyMixin';
+import { CubeRegionModel } from '../../../regions/CubeRegion';
+import * as Tools from '../../../tools';
+import ToolsManager from '../../../tools/Manager';
+import { parseValue } from '../../../utils/data';
+import { guidGenerator } from '../../../utils/unique';
+import { clamp, isDefined } from '../../../utils/utilities';
+import ObjectBase from '../Base';
+import { DrawingRegion } from './DrawingRegion';
+import { Object3DEntityMixin } from './Object3DEntityMixin';
+import { Object3DSelection } from './Object3DSelection';
+import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH, SNAP_TO_PIXEL_MODE } from '../../../components/Object3DView/Object3D';
+
+const IMAGE_PRELOAD_COUNT = 3;
+
+/**
+ * The `Object3D` tag shows an object3d on the page. Use for all object3d annotation tasks to display an object3d on the labeling interface.
+ *
+ * Use with the following data types: object3ds.
+ *
+ * When you annotate object3d regions with this tag, the annotations are saved as percentages of the original size of the object3d, from 0-100.
+ *
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @name Object3D
+ * @meta_title Object3D Tags for Object3Ds
+ * @meta_description Customize Label Studio with the Object3D tag to annotate object3ds for computer vision machine learning and data science projects.
+ * @param {string} name - Name of the element
+ * @param {string} value - Data field containing a path or URL to the object3d
+ * @param {string} [valueList] - References a variable that holds a list of object3d URLs
+ * @param {boolean} [smoothing] - Enable smoothing, by default it uses user settings
+ * @param {string=} [width=100%] - Object3D width
+ * @param {string=} [maxWidth=750px] - Maximum object3d width
+ * @param {boolean=} [zoom=false] - Enable zooming an object3d with the mouse wheel
+ * @param {boolean=} [negativeZoom=false] - Enable zooming out an object3d
+ * @param {float=} [zoomBy=1.1] - Scale factor
+ * @param {boolean=} [grid=false] - Whether to show a grid
+ * @param {number=} [gridSize=30] - Specify size of the grid
+ * @param {string=} [gridColor=#EEEEF4] - Color of the grid in hex, opacity is 0.15
+ * @param {boolean} [zoomControl=false] - Show zoom controls in toolbar
+ * @param {boolean} [brightnessControl=false] - Show brightness control in toolbar
+ * @param {boolean} [contrastControl=false] - Show contrast control in toolbar
+ * @param {boolean} [rotateControl=false] - Show rotate control in toolbar
+ * @param {boolean} [crosshair=false] - Show crosshair cursor
+ * @param {left|center|right} [horizontalAlignment=left] - Where to align object3d horizontally. Can be one of "left", "center", or "right"
+ * @param {top|center|bottom} [verticalAlignment=top] - Where to align object3d vertically. Can be one of "top", "center", or "bottom"
+ * @param {auto|original|fit} [defaultZoom=fit] - Specify the initial zoom of the object3d within the viewport while preserving its ratio. Can be one of "auto", "original", or "fit"
+ * @param {none|anonymous|use-credentials} [crossOrigin=none] - Configures CORS cross domain behavior for this object3d, either "none", "anonymous", or "use-credentials", similar to [DOM `img` crossOrigin property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/crossOrigin).
+ */
+const TagAttrs = types.model({
+ value: types.maybeNull(types.string),
+ valuelist: types.maybeNull(types.string),
+ resize: types.maybeNull(types.number),
+ width: types.optional(types.string, '100%'),
+ height: types.maybeNull(types.string),
+ maxwidth: types.optional(types.string, '100%'),
+ maxheight: types.optional(types.string, 'calc(100vh - 194px)'),
+ smoothing: types.maybeNull(types.boolean),
+
+ // rulers: types.optional(types.boolean, true),
+ grid: types.optional(types.boolean, false),
+ gridsize: types.optional(types.string, '30'),
+ gridcolor: types.optional(customTypes.color, '#EEEEF4'),
+
+ zoom: types.optional(types.boolean, true),
+ negativezoom: types.optional(types.boolean, false),
+ zoomby: types.optional(types.string, '1.1'),
+
+ showlabels: types.optional(types.boolean, false),
+
+ zoomcontrol: types.optional(types.boolean, true),
+ brightnesscontrol: types.optional(types.boolean, false),
+ contrastcontrol: types.optional(types.boolean, false),
+ rotatecontrol: types.optional(types.boolean, false),
+ crosshair: types.optional(types.boolean, false),
+ selectioncontrol: types.optional(types.boolean, true),
+
+ // this property is just to turn lazyload off to e2e tests
+ lazyoff: types.optional(types.boolean, false),
+
+ horizontalalignment: types.optional(types.enumeration(['left', 'center', 'right']), 'left'),
+ verticalalignment: types.optional(types.enumeration(['top', 'center', 'bottom']), 'top'),
+ defaultzoom: types.optional(types.enumeration(['auto', 'original', 'fit']), 'fit'),
+
+ crossorigin: types.optional(types.enumeration(['none', 'anonymous', 'use-credentials']), 'none'),
+});
+
+const IMAGE_CONSTANTS = {
+ cubeModel: 'CubeModel',
+ cubeLabelsModel: 'CubeLabelsModel',
+ cubelabels: 'cubelabels',
+};
+
+const Model = types.model({
+ type: 'object3d',
+
+ // tools: types.array(BaseTool),
+
+ sizeUpdated: types.optional(types.boolean, false),
+
+ /**
+ * Cursor coordinates
+ */
+ cursorPositionX: types.optional(types.number, 0),
+ cursorPositionY: types.optional(types.number, 0),
+
+ brushControl: types.optional(types.string, 'brush'),
+
+ brushStrokeWidth: types.optional(types.number, 15),
+
+ /**
+ * Mode
+ * brush for Object3D Segmentation
+ * eraser for Object3D Segmentation
+ */
+ mode: types.optional(types.enumeration(['drawing', 'viewing']), 'viewing'),
+
+ regions: types.array(
+ types.union(CubeRegionModel),
+ [],
+ ),
+
+ drawingRegion: types.optional(DrawingRegion, null),
+ selectionArea: types.optional(Object3DSelection, { start: null, end: null }),
+}).volatile(() => ({
+ currentObject3D: undefined,
+ supportSuggestions: true,
+})).views(self => ({
+ get store() {
+ return getRoot(self);
+ },
+
+ get multiObject3D() {
+ return !!self.isMultiItem;
+ },
+
+ // an alias of currentObject3D to make an interface reusable
+ get currentItemIndex() {
+ return self.currentObject3D;
+ },
+
+ get parsedValue() {
+ return parseValue(self.value, self.store.task.dataObj);
+ },
+
+ get parsedValueList() {
+ return parseValue(self.valuelist, self.store.task.dataObj);
+ },
+
+ get currentSrc() {
+ return self.currentObject3DEntity.src;
+ },
+
+ get usedValue() {
+ return self.multiObject3D ? self.valuelist : self.value;
+ },
+
+ get object3ds() {
+ const value = self.parsedValue;
+
+ if (!value) return [];
+ if (Array.isArray(value)) return value;
+ return [value];
+ },
+
+ /**
+ * @return {boolean}
+ */
+ get hasStates() {
+ const states = self.states();
+
+ return states && states.length > 0;
+ },
+
+ get selectedRegions() {
+ return self.regs.filter(region => region.inSelection);
+ },
+
+ get selectedRegionsBBox() {
+ let bboxCoords;
+
+ self.selectedRegions.forEach((region) => {
+ const regionBBox = region.bboxCoords;
+
+ if (!regionBBox) return;
+
+ if (bboxCoords) {
+ bboxCoords = {
+ left: Math.min(regionBBox?.left, bboxCoords.left),
+ top: Math.min(regionBBox?.top, bboxCoords.top),
+ right: Math.max(regionBBox?.right, bboxCoords.right),
+ bottom: Math.max(regionBBox?.bottom, bboxCoords.bottom),
+ };
+ } else {
+ bboxCoords = regionBBox;
+ }
+ });
+ return bboxCoords;
+ },
+
+ get regionsInSelectionArea() {
+ return self.regs.filter(region => region.isInSelectionArea);
+ },
+
+ get selectedShape() {
+ return self.regs.find(r => r.selected);
+ },
+
+ get suggestions() {
+ return self.annotation?.regionStore.suggestions.filter(r => r.object === self) || [];
+ },
+
+ get useTransformer() {
+ return self.getToolsManager().findSelectedTool()?.useTransformer === true;
+ },
+
+ get stageTranslate() {
+ const {
+ stageWidth: width,
+ stageHeight: height,
+ } = self;
+
+ return {
+ 0: { x: 0, y: 0 },
+ 90: { x: 0, y: height },
+ 180: { x: width, y: height },
+ 270: { x: width, y: 0 },
+ }[self.rotation];
+ },
+
+ get stageScale() {
+ return self.zoomScale;
+ },
+
+ get hasTools() {
+ return !!self.getToolsManager().allTools()?.length;
+ },
+
+ get object3dCrossOrigin() {
+ const value = self.crossorigin.toLowerCase();
+
+ if (!value || value === 'none') {
+ return null;
+ } else {
+ return value;
+ }
+ },
+
+ get fillerHeight() {
+ const { naturalWidth, naturalHeight } = self;
+
+ return self.isSideways
+ ? `${naturalWidth / naturalHeight * 100}%`
+ : `${naturalHeight / naturalWidth * 100}%`;
+ },
+
+ get zoomedPixelSize() {
+ const { naturalWidth, naturalHeight } = self;
+
+ return {
+ x: self.stageWidth / naturalWidth,
+ y: self.stageHeight / naturalHeight,
+ };
+
+ },
+
+ isSamePixel({ x: x1, y: y1 }, { x: x2, y: y2 }) {
+ const zoomedPixelSizeX = self.zoomedPixelSize.x;
+ const zoomedPixelSizeY = self.zoomedPixelSize.y;
+
+ return Math.abs(x1 - x2) < zoomedPixelSizeX / 2 && Math.abs(y1 - y2) < zoomedPixelSizeY / 2;
+ },
+
+ snapPointToPixel({ x,y }, snapMode = SNAP_TO_PIXEL_MODE.EDGE) {
+ const zoomedPixelSizeX = self.zoomedPixelSize.x;
+ const zoomedPixelSizeY = self.zoomedPixelSize.y;
+
+ switch (snapMode) {
+ case SNAP_TO_PIXEL_MODE.EDGE: {
+ return {
+ x: Math.round(x / zoomedPixelSizeX) * zoomedPixelSizeX,
+ y: Math.round(y / zoomedPixelSizeY) * zoomedPixelSizeY,
+ };
+ }
+ case SNAP_TO_PIXEL_MODE.CENTER: {
+ return {
+ x: Math.floor(x / zoomedPixelSizeX) * zoomedPixelSizeX + zoomedPixelSizeX / 2,
+ y: Math.floor(y / zoomedPixelSizeY) * zoomedPixelSizeY + zoomedPixelSizeY / 2,
+ };
+ }
+ }
+ },
+
+ createSerializedResult(region, value) {
+ const index = region.item_index ?? 0;
+ const currentObject3DEntity = self.findObject3DEntity(index);
+
+ const object3dDimension = {
+ original_width: currentObject3DEntity.naturalWidth,
+ original_height: currentObject3DEntity.naturalHeight,
+ object3d_rotation: currentObject3DEntity.rotation,
+ };
+
+ if (self.multiObject3D && isDefined(index)) {
+ object3dDimension.item_index = index;
+ }
+
+ // We're using raw region result instead of calulated one when
+ // the object3d data is not available (object3d is not yet loaded)
+ // As the serialization also happens during region creation,
+ // we have to forsee this scenario and avoid using raw result
+ // as it can only be present for already created (submitter) regions
+ const useRawResult = !currentObject3DEntity.object3dLoaded && isDefined(region._rawResult);
+
+ return useRawResult ? structuredClone(region._rawResult) : {
+ ...object3dDimension,
+ value,
+ };
+ },
+
+ /**
+ * @return {object}
+ */
+ states() {
+ return self.annotation.toNames.get(self.name);
+ },
+
+ activeStates() {
+ const states = self.states();
+
+ return states && states.filter(s => s.isSelected && s.type.includes('labels'));
+ },
+
+ controlButton() {
+ const names = self.states();
+
+ if (!names || names.length === 0) return;
+
+ let returnedControl = names[0];
+
+ names.forEach(item => {
+ if (
+ item.type === IMAGE_CONSTANTS.cubelabels
+ ) {
+ returnedControl = item;
+ }
+ });
+
+ return returnedControl;
+ },
+
+ get controlButtonType() {
+ const name = self.controlButton();
+
+ return getType(name).name;
+ },
+
+ get isSideways() {
+ return (self.rotation + 360) % 180 === 90;
+ },
+
+ get stageComponentSize() {
+ if (self.isSideways) {
+ return {
+ width: self.stageHeight,
+ height: self.stageWidth,
+ };
+ }
+ return {
+ width: self.stageWidth,
+ height: self.stageHeight,
+ };
+ },
+
+ get canvasSize() {
+ if (self.isSideways) {
+ return {
+ width: Math.round(self.naturalHeight * self.stageZoomX),
+ height: Math.round(self.naturalWidth * self.stageZoomY),
+ };
+ }
+
+ return {
+ width: Math.round(self.naturalWidth * self.stageZoomX),
+ height: Math.round(self.naturalHeight * self.stageZoomY),
+ };
+ },
+
+ get alignmentOffset() {
+ const offset = { x: 0, y: 0 };
+
+ return offset;
+ },
+
+ get zoomBy() {
+ return parseFloat(self.zoomby);
+ },
+ get isDrawing() {
+ return !!self.drawingRegion;
+ },
+
+ get object3dTransform() {
+ const imgStyle = {
+ // scale transform leaves gaps on object3d border, so much better to change object3d sizes
+ width: `${self.stageWidth * self.zoomScale}px`,
+ height: `${self.stageHeight * self.zoomScale}px`,
+ transformOrigin: 'left top',
+ // We should always set some transform to make the object3d rendering in the same way all the time
+ transform: 'translate3d(0,0,0)',
+ filter: `brightness(${self.brightnessGrade}%) contrast(${self.contrastGrade}%)`,
+ };
+ const imgTransform = [];
+
+ if (self.zoomScale !== 1) {
+ const { zoomingPositionX = 0, zoomingPositionY = 0 } = self;
+
+ imgTransform.push('translate3d(' + zoomingPositionX + 'px,' + zoomingPositionY + 'px, 0)');
+ }
+
+ if (self.rotation) {
+ const translate = {
+ 90: '0, -100%',
+ 180: '-100%, -100%',
+ 270: '-100%, 0',
+ };
+
+ // there is a top left origin already set for zoom; so translate+rotate
+ imgTransform.push(`rotate(${self.rotation}deg)`);
+ imgTransform.push(`translate(${translate[self.rotation] || '0, 0'})`);
+
+ }
+
+ if (imgTransform?.length > 0) {
+ imgStyle.transform = imgTransform.join(' ');
+ }
+ return imgStyle;
+ },
+
+ get maxScale() {
+ return self.isSideways
+ ? Math.min(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth)
+ : Math.min(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight);
+ },
+
+ get coverScale() {
+ return self.isSideways
+ ? Math.max(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth)
+ : Math.max(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight);
+ },
+
+ get viewPortBBoxCoords() {
+ let width = self.canvasSize.width / self.zoomScale;
+ let height = self.canvasSize.height / self.zoomScale;
+ const leftOffset = -self.zoomingPositionX / self.zoomScale;
+ const topOffset = -self.zoomingPositionY / self.zoomScale;
+ const rightOffset = self.stageComponentSize.width - (leftOffset + width);
+ const bottomOffset = self.stageComponentSize.height - (topOffset + height);
+ const offsets = [leftOffset, topOffset, rightOffset, bottomOffset];
+
+ if (self.isSideways) {
+ [width, height] = [height, width];
+ }
+ if (self.rotation) {
+ const rotateCount = (self.rotation / 90) % 4;
+
+ for (let k = 0; k < rotateCount; k++) {
+ offsets.push(offsets.shift());
+ }
+ }
+ const left = offsets[0];
+ const top = offsets[1];
+
+ return {
+ left,
+ top,
+ right: left + width,
+ bottom: top + height,
+ width,
+ height,
+ };
+ },
+}))
+
+ // actions for the tools
+ .actions(self => {
+ const manager = ToolsManager.getInstance({ name: self.name });
+ const env = { manager, control: self, object: self };
+
+ function createObject3DEntities() {
+ if (!self.store.task) return;
+
+ const parsedValue = self.multiObject3D
+ ? self.parsedValueList
+ : self.parsedValue;
+
+ if (Array.isArray(parsedValue)) {
+ parsedValue.forEach((src, index) => {
+ self.object3dEntities.push({
+ id: `${self.name}#${index}`,
+ src,
+ index,
+ });
+ });
+ } else {
+ self.object3dEntities.push({
+ id: `${self.name}#0`,
+ src: parsedValue,
+ index: 0,
+ });
+ }
+
+ self.setCurrentObject3D(0);
+ }
+
+ function afterAttach() {
+ if (self.selectioncontrol)
+ manager.addTool('MoveTool', Tools.Selection.create({}, env));
+
+ if (self.zoomcontrol)
+ manager.addTool('ZoomPanTool', Tools.Zoom.create({}, env));
+
+ if (self.brightnesscontrol)
+ manager.addTool('BrightnessTool', Tools.Brightness.create({}, env));
+
+ if (self.contrastcontrol)
+ manager.addTool('ContrastTool', Tools.Contrast.create({}, env));
+
+ if (self.rotatecontrol)
+ manager.addTool('RotateTool', Tools.Rotate.create({}, env));
+
+ createObject3DEntities();
+ }
+
+ function afterResultCreated(region) {
+ if (!region) return;
+ if (region.classification) return;
+ if (!self.multiObject3D) return;
+
+ region.setItemIndex?.(self.currentObject3D);
+ }
+
+ function getToolsManager() {
+ return manager;
+ }
+
+ return {
+ afterAttach,
+ getToolsManager,
+ afterResultCreated,
+ };
+ }).extend((self) => {
+ let skipInteractions = false;
+
+ return {
+ views: {
+ getSkipInteractions() {
+ const manager = self.getToolsManager();
+
+ const isPanning = manager.findSelectedTool()?.toolName === 'ZoomPanTool';
+
+ return skipInteractions || isPanning;
+ },
+ },
+ actions: {
+ setSkipInteractions(value) {
+ skipInteractions = value;
+ },
+ updateSkipInteractions(e) {
+ const currentTool = self.getToolsManager().findSelectedTool();
+
+ if (currentTool?.shouldSkipInteractions) {
+ return self.setSkipInteractions(currentTool.shouldSkipInteractions(e));
+ }
+ self.setSkipInteractions(e.evt && (e.evt.metaKey || e.evt.ctrlKey));
+ },
+ },
+ };
+ }).actions(self => ({
+ freezeHistory() {
+ //self.annotation.history.freeze();
+ },
+
+ afterRegionSelected(region) {
+ if (self.multiObject3D) {
+ self.setCurrentObject3D(region.item_index);
+ }
+ },
+
+ createDrawingRegion(areaValue, resultValue, control, dynamic) {
+ const controlTag = self.annotation.names.get(control.name);
+
+ const result = {
+ from_name: controlTag,
+ to_name: self,
+ type: control.resultType,
+ value: resultValue,
+ };
+
+ const areaRaw = {
+ id: guidGenerator(),
+ object: self,
+ ...areaValue,
+ results: [result],
+ dynamic,
+ item_index: self.currentObject3D,
+ };
+
+ self.drawingRegion = areaRaw;
+ return self.drawingRegion;
+ },
+
+ deleteDrawingRegion() {
+ const { drawingRegion } = self;
+
+ if (!drawingRegion) return;
+ self.drawingRegion = null;
+ destroy(drawingRegion);
+ },
+
+ setSelectionStart(point) {
+ self.selectionArea.setStart(point);
+ },
+ setSelectionEnd(point) {
+ self.selectionArea.setEnd(point);
+ },
+ resetSelection() {
+ self.selectionArea.setStart(null);
+ self.selectionArea.setEnd(null);
+ },
+
+ updateBrushControl(arg) {
+ self.brushControl = arg;
+ },
+
+ updateBrushStrokeWidth(arg) {
+ self.brushStrokeWidth = arg;
+ },
+
+ /**
+ * Update brightnessGrade of Object3D
+ * @param {number} value
+ */
+ setBrightnessGrade(value) {
+ self.brightnessGrade = value;
+ },
+
+ setContrastGrade(value) {
+ self.contrastGrade = value;
+ },
+
+ setGridSize(value) {
+ self.gridsize = String(value);
+ },
+
+ // an alias of setCurrentObject3D for making an interface reusable
+ setCurrentItem(index = 0) {
+ self.setCurrentObject3D(index);
+ },
+
+ setCurrentObject3D(index = 0) {
+ index = index ?? 0;
+ if (index === self.currentObject3D) return;
+
+ self.currentObject3D = index;
+ self.currentObject3DEntity = self.findObject3DEntity(index);
+ },
+
+ preloadObject3Ds() {
+ self.currentObject3DEntity.setObject3DLoaded(false);
+ self.currentObject3DEntity.preload();
+
+ if (self.multiObject3D) {
+ const [currentIndex, length] = [self.currentObject3D, self.object3dEntities.length];
+ const prevSliceIndex = clamp(currentIndex - IMAGE_PRELOAD_COUNT, 0, currentIndex);
+ const nextSliceIndex = clamp(currentIndex + 1 + IMAGE_PRELOAD_COUNT, currentIndex, length - 1);
+
+ const object3ds = [
+ ...self.object3dEntities.slice(prevSliceIndex, currentIndex),
+ ...self.object3dEntities.slice(currentIndex + 1, nextSliceIndex),
+ ];
+
+ object3ds.forEach((object3dEntity) => {
+ object3dEntity.preload();
+ });
+ }
+ },
+
+ /**
+ * Set pointer of X and Y
+ */
+ setPointerPosition({ x, y }) {
+ self.freezeHistory();
+ self.cursorPositionX = x;
+ self.cursorPositionY = y;
+ },
+
+ /**
+ * Set zoom
+ */
+ setZoom(scale) {
+ scale = clamp(scale, 1, Infinity);
+ self.currentZoom = scale;
+
+ // cool comment about all this stuff
+ const maxScale = self.maxScale;
+ const coverScale = self.coverScale;
+
+ if (maxScale > 1) { // object3d < container
+ if (scale < maxScale) { // scale = 1 or before stage size is max
+ self.stageZoom = scale; // scale stage
+ self.zoomScale = 1; // don't scale object3d
+ } else {
+ self.stageZoom = maxScale; // scale stage to max
+ self.zoomScale = scale / maxScale; // scale object3d for the rest scale
+ }
+ } else { // object3d > container
+ if (scale > maxScale) { // scale = 1 or any other zoom bigger then viewport
+ self.stageZoom = maxScale; // stage squizzed
+ self.zoomScale = scale; // scale object3d for the rest scale : scale object3d usually
+ } else { // negative zoom bigger than object3d negative scale
+ self.stageZoom = scale; // squize stage more
+ self.zoomScale = 1; // don't scale object3d
+ }
+ }
+
+ if (self.zoomScale > 1) {
+ // zoomScale scales object3d above maxScale, so scale the rest of stage the same way
+ const z = Math.min(maxScale * self.zoomScale, coverScale);
+
+ if (self.containerWidth / self.naturalWidth > self.containerHeight / self.naturalHeight) {
+ self.stageZoomX = z;
+ self.stageZoomY = self.stageZoom;
+ } else {
+ self.stageZoomX = self.stageZoom;
+ self.stageZoomY = z;
+ }
+ } else {
+ self.stageZoomX = self.stageZoom;
+ self.stageZoomY = self.stageZoom;
+ }
+ },
+
+ updateObject3DAfterZoom() {
+ const { stageWidth, stageHeight } = self;
+
+ self._recalculateObject3DParams();
+
+ if (stageWidth !== self.stageWidth || stageHeight !== self.stageHeight) {
+ self._updateRegionsSizes({
+ width: self.stageWidth,
+ height: self.stageHeight,
+ naturalWidth: self.naturalWidth,
+ naturalHeight: self.naturalHeight,
+ });
+ }
+ },
+
+ setZoomPosition(x, y) {
+ const [width, height] = [self.containerWidth, self.containerHeight];
+
+ const [minX, minY] = [
+ width - self.stageComponentSize.width * self.zoomScale,
+ height - self.stageComponentSize.height * self.zoomScale,
+ ];
+
+ self.zoomingPositionX = clamp(x, minX, 0);
+ self.zoomingPositionY = clamp(y, minY, 0);
+ },
+
+ resetZoomPositionToCenter() {
+ const { stageComponentSize, zoomScale } = self;
+ const { width, height } = stageComponentSize;
+
+ const [containerWidth, containerHeight] = [self.containerWidth, self.containerHeight];
+
+ self.setZoomPosition((containerWidth - width * zoomScale) / 2, (containerHeight - height * zoomScale) / 2);
+ },
+
+ sizeToFit() {
+ const { maxScale } = self;
+
+ self.defaultzoom = 'fit';
+ self.setZoom(maxScale);
+ self.updateObject3DAfterZoom();
+ self.resetZoomPositionToCenter();
+ },
+
+ sizeToOriginal() {
+ const { maxScale } = self;
+
+ self.defaultzoom = 'original';
+ self.setZoom(maxScale > 1 ? 1 : 1 / maxScale);
+ self.updateObject3DAfterZoom();
+ self.resetZoomPositionToCenter();
+ },
+
+ sizeToAuto() {
+ self.defaultzoom = 'auto';
+ self.setZoom(1);
+ self.updateObject3DAfterZoom();
+ self.resetZoomPositionToCenter();
+ },
+
+ handleZoom(val, mouseRelativePos = { x: self.canvasSize.width / 2, y: self.canvasSize.height / 2 }) {
+ if (val) {
+ let zoomScale = self.currentZoom;
+
+ zoomScale = val > 0 ? zoomScale * self.zoomBy : zoomScale / self.zoomBy;
+ if (self.negativezoom !== true && zoomScale <= 1) {
+ self.setZoom(1);
+ self.setZoomPosition(0, 0);
+ self.updateObject3DAfterZoom();
+ return;
+ }
+ if (zoomScale <= 1) {
+ self.setZoom(zoomScale);
+ self.setZoomPosition(0, 0);
+ self.updateObject3DAfterZoom();
+ return;
+ }
+
+ // DON'T TOUCH THIS
+ let stageScale = self.zoomScale;
+
+ const mouseAbsolutePos = {
+ x: (mouseRelativePos.x - self.zoomingPositionX) / stageScale,
+ y: (mouseRelativePos.y - self.zoomingPositionY) / stageScale,
+ };
+
+ self.setZoom(zoomScale);
+
+ stageScale = self.zoomScale;
+
+ const zoomingPosition = {
+ x: -(mouseAbsolutePos.x - mouseRelativePos.x / stageScale) * stageScale,
+ y: -(mouseAbsolutePos.y - mouseRelativePos.y / stageScale) * stageScale,
+ };
+
+ self.setZoomPosition(zoomingPosition.x, zoomingPosition.y);
+ self.updateObject3DAfterZoom();
+ }
+ },
+
+ /**
+ * Set mode of Object3D (drawing and viewing)
+ * @param {string} mode
+ */
+ setMode(mode) {
+ self.mode = mode;
+ },
+
+ setObject3DRef(ref) {
+ self.object3dRef = ref;
+ },
+
+ setContainerRef(ref) {
+ self.containerRef = ref;
+ },
+
+ setStageRef(ref) {
+ self.stageRef = ref;
+
+ const currentTool = self.getToolsManager().findSelectedTool();
+
+ currentTool?.updateCursor?.();
+ },
+
+ setOverlayRef(ref) {
+ self.overlayRef = ref;
+ },
+
+ // @todo remove
+ setSelected() {
+ // self.selectedShape = shape;
+ },
+
+ rotate(degree = -90) {
+ self.rotation = (self.rotation + degree + 360) % 360;
+
+ let ratioK = 1 / self.stageRatio;
+
+ if (self.isSideways) {
+ self.stageRatio = self.naturalWidth / self.naturalHeight;
+ } else {
+ self.stageRatio = 1;
+ }
+ ratioK = ratioK * self.stageRatio;
+
+ self.setZoom(self.currentZoom);
+
+ if (degree === -90) {
+ this.setZoomPosition(
+ self.zoomingPositionY * ratioK,
+ self.stageComponentSize.height -
+ self.zoomingPositionX * ratioK -
+ self.stageComponentSize.height * self.zoomScale,
+ );
+ }
+ if (degree === 90) {
+ this.setZoomPosition(
+ self.stageComponentSize.width -
+ self.zoomingPositionY * ratioK -
+ self.stageComponentSize.width * self.zoomScale,
+ self.zoomingPositionX * ratioK,
+ );
+ }
+
+ self.updateObject3DAfterZoom();
+ },
+
+ _recalculateObject3DParams() {
+ self.stageWidth = Math.round(self.naturalWidth * self.stageZoom);
+ self.stageHeight = Math.round(self.naturalHeight * self.stageZoom);
+ },
+
+ _updateObject3DSize({ width, height, userResize }) {
+ if (self.naturalWidth === undefined) {
+ return;
+ }
+ if (width > 1 && height > 1) {
+ const prevWidth = self.canvasSize.width;
+ const prevHeight = self.canvasSize.height;
+ const prevStageZoom = self.stageZoom;
+ const prevZoomScale = self.zoomScale;
+
+ self.containerWidth = width;
+ self.containerHeight = height;
+
+ // reinit zoom to calc stageW/H
+ self.setZoom(self.currentZoom);
+
+ self._recalculateObject3DParams();
+
+ const zoomChangeRatio = self.stageZoom / prevStageZoom;
+ const scaleChangeRatio = self.zoomScale / prevZoomScale;
+ const changeRatio = zoomChangeRatio * scaleChangeRatio;
+
+
+ self.setZoomPosition(
+ self.zoomingPositionX * changeRatio + (self.canvasSize.width / 2 - prevWidth / 2 * changeRatio),
+ self.zoomingPositionY * changeRatio + (self.canvasSize.height / 2 - prevHeight / 2 * changeRatio),
+ );
+ }
+
+ self.sizeUpdated = true;
+ self._updateRegionsSizes({
+ width: self.stageWidth,
+ height: self.stageHeight,
+ naturalWidth: self.naturalWidth,
+ naturalHeight: self.naturalHeight,
+ userResize,
+ });
+ },
+
+ _updateRegionsSizes({ width, height, naturalWidth, naturalHeight, userResize }) {
+ const _historyLength = self.annotation?.history?.history?.length;
+
+ self.annotation.history.freeze();
+
+ self.regions.forEach(shape => {
+ shape.updateObject3DSize(width / naturalWidth, height / naturalHeight, width, height, userResize);
+ });
+ self.regs.forEach(shape => {
+ shape.updateObject3DSize(width / naturalWidth, height / naturalHeight, width, height, userResize);
+ });
+ self.drawingRegion?.updateObject3DSize(width / naturalWidth, height / naturalHeight, width, height, userResize);
+
+ setTimeout(self.annotation.history.unfreeze, 0);
+
+ //sometimes when user zoomed in, annotation was creating a new history. This fix that in case the user has nothing in the history yet
+ if (_historyLength <= 1) {
+ // Don't force unselection of regions during the updateObjects callback from history reinit
+ setTimeout(() => self.annotation?.reinitHistory(false), 0);
+ }
+ },
+
+ updateObject3DSize(ev) {
+ const { naturalWidth, naturalHeight } = self.object3dRef ?? ev.target;
+ const { offsetWidth, offsetHeight } = self.containerRef;
+
+ self.naturalWidth = naturalWidth;
+ self.naturalHeight = naturalHeight;
+
+ self._updateObject3DSize({ width: offsetWidth, height: offsetHeight });
+ // after regions' sizes adjustment we have to reset all saved history changes
+ // mobx do some batch update here, so we have to reset it asynchronously
+ // this happens only after initial load, so it's safe
+ self.setReady(true);
+
+ if (self.defaultzoom === 'fit') {
+ self.sizeToFit();
+ } else {
+ self.sizeToAuto();
+ }
+ // Don't force unselection of regions during the updateObjects callback from history reinit
+ setTimeout(() => self.annotation?.reinitHistory(false), 0);
+ },
+
+ checkLabels() {
+ const labelStates = (self.states() || []).filter(s => s.type.includes('labels'));
+ const selectedStates = self.getAvailableStates();
+
+ return selectedStates.length !== 0 || labelStates.length === 0;
+ },
+
+ addShape(shape) {
+ self.regions.push(shape);
+ self.annotation.addRegion(shape);
+ self.setSelected(shape.id);
+ shape.selectRegion();
+ },
+
+ /**
+ * Resize of object3d canvas
+ * @param {*} width
+ * @param {*} height
+ */
+ onResize(width, height, userResize) {
+ self._updateObject3DSize({ width, height, userResize });
+ },
+
+ event(name, ev, screenX, screenY) {
+ const [canvasX, canvasY] = self.fixZoomedCoords([screenX, screenY]);
+
+ const x = self.canvasToInternalX(canvasX);
+ const y = self.canvasToInternalY(canvasY);
+
+ self.getToolsManager().event(name, ev.evt || ev, x, y, canvasX, canvasY);
+ },
+ }));
+
+const CoordsCalculations = types.model()
+ .actions(self => ({
+ // convert screen coords to object3d coords considering zoom
+ fixZoomedCoords([x, y]) {
+ if (!self.stageRef) {
+ return [x, y];
+ }
+
+ // good official way, but maybe a bit slower and with repeating cloning
+ const p = self.stageRef.getAbsoluteTransform().copy().invert().point({ x, y });
+
+ return [p.x, p.y];
+ },
+
+ // convert object3d coords to screen coords considering zoom
+ zoomOriginalCoords([x, y]) {
+ const p = self.stageRef.getAbsoluteTransform().point({ x, y });
+
+ return [p.x, p.y];
+ },
+
+ /**
+ * @typedef {number[]|{ x: number, y: number }} Point
+ */
+
+ /**
+ * @callback PointFn
+ * @param {Point} point
+ * @returns Point
+ */
+
+ /**
+ * Wrap point operations to convert zoomed coords from screen to object3d and back
+ * Good for event handlers, receiving screen coords, but working with object3d coords
+ * Accepts both [x, y] and {x, y} points; preserves this format
+ * @param {PointFn} fn wrapped function do some math with object3d coords
+ * @return {PointFn} outer function do some math with screen coords
+ */
+ fixForZoom(fn) {
+ return p => this.fixForZoomWrapper(p, fn);
+ },
+ fixForZoomWrapper(p, fn) {
+ const asArray = p.x === undefined;
+ const [x, y] = self.fixZoomedCoords(asArray ? p : [p.x, p.y]);
+ const modified = fn(asArray ? [x, y] : { x, y });
+ const zoomed = self.zoomOriginalCoords(asArray ? modified : [modified.x, modified.y]);
+
+ return asArray ? zoomed : { x: zoomed[0], y: zoomed[1] };
+ },
+ }))
+ // putting this transforms to views forces other getters to be recalculated on resize
+ .views(self => ({
+ // helps to calculate rotation because internal coords are square and real one usually aren't
+ get whRatio() {
+ return self.stageWidth / self.stageHeight;
+ },
+
+ // @todo scale?
+ canvasToInternalX(n) {
+ return n / self.stageWidth * RELATIVE_STAGE_WIDTH;
+ },
+
+ canvasToInternalY(n) {
+ return n / self.stageHeight * RELATIVE_STAGE_HEIGHT;
+ },
+
+ internalToCanvasX(n) {
+ return n / RELATIVE_STAGE_WIDTH * self.stageWidth;
+ },
+
+ internalToCanvasY(n) {
+ return n / RELATIVE_STAGE_HEIGHT * self.stageHeight;
+ },
+ }));
+
+// mock coords calculations to transparently pass coords with FF 3793 off
+const AbsoluteCoordsCalculations = CoordsCalculations
+ .views(() => ({
+ canvasToInternalX(n) {
+ return n;
+ },
+ canvasToInternalY(n) {
+ return n;
+ },
+ internalToCanvasX(n) {
+ return n;
+ },
+ internalToCanvasY(n) {
+ return n;
+ },
+ }));
+
+const Object3DModel = types.compose(
+ 'Object3DModel',
+ TagAttrs,
+ ObjectBase,
+ AnnotationMixin,
+ IsReadyWithDepsMixin,
+ Object3DEntityMixin,
+ Model,
+ AbsoluteCoordsCalculations,
+);
+
+const HtxObject3D = inject('store')(Object3DView);
+
+Registry.addTag('object3d', Object3DModel, HtxObject3D);
+Registry.addObjectType(Object3DModel);
+
+export { Object3DModel, HtxObject3D };
diff --git a/src/tags/object/Object3D/Object3DEntity.js b/src/tags/object/Object3D/Object3DEntity.js
new file mode 100644
index 0000000000..674a306f5d
--- /dev/null
+++ b/src/tags/object/Object3D/Object3DEntity.js
@@ -0,0 +1,189 @@
+import { types } from 'mobx-state-tree';
+import { FileLoader } from '../../../utils/FileLoader';
+import { clamp } from '../../../utils/utilities';
+
+const fileLoader = new FileLoader();
+
+export const Object3DEntity = types.model({
+ id: types.identifier,
+ src: types.string,
+ index: types.number,
+
+ rotation: types.optional(types.number, 0),
+
+ /**
+ * Natural sizes of Object3D
+ * Constants
+ */
+ naturalWidth: types.optional(types.integer, 1),
+ naturalHeight: types.optional(types.integer, 1),
+
+ stageWidth: types.optional(types.number, 1),
+ stageHeight: types.optional(types.number, 1),
+
+ /**
+ * Zoom Scale
+ */
+ zoomScale: types.optional(types.number, 1),
+
+ /**
+ * Coordinates of left top corner
+ * Default: { x: 0, y: 0 }
+ */
+ zoomingPositionX: types.optional(types.number, 0),
+ zoomingPositionY: types.optional(types.number, 0),
+
+ /**
+ * Brightness of Canvas
+ */
+ brightnessGrade: types.optional(types.number, 100),
+
+ contrastGrade: types.optional(types.number, 100),
+}).volatile(() => ({
+ stageRatio: 1,
+ // Container's sizes causing limits to calculate a scale factor
+ containerWidth: 1,
+ containerHeight: 1,
+
+ stageZoom: 1,
+ stageZoomX: 1,
+ stageZoomY: 1,
+ currentZoom: 1,
+
+ /** Is object3d downloaded to local cache */
+ downloaded: false,
+ /** Is object3d being downloaded */
+ downloading: false,
+ /** If error happened during download */
+ error: false,
+ /** Download progress 0..1 */
+ progress: 0,
+ /** Local object3d src created with URL.createURLObject */
+ currentSrc: undefined,
+ /** Is object3d loaded using `
` tag and cached by the browser */
+ object3dLoaded: false,
+})).actions((self) => ({
+ preload() {
+ if (self.ensurePreloaded()) return;
+
+ self.setDownloading(true);
+
+ fileLoader.download(self.src, (_t, _l, progress) => {
+ self.setProgress(progress);
+ }).then((url) => {
+ self.setDownloaded(true);
+ self.setDownloading(false);
+ self.setCurrentSrc(url);
+ }).catch(() => {
+ self.setDownloading(false);
+ self.setError(true);
+ });
+ },
+
+ ensurePreloaded() {
+ if (fileLoader.isError(self.src)) {
+ self.setDownloading(false);
+ self.setError(true);
+ return true;
+ } else if (fileLoader.isPreloaded(self.src)) {
+ self.setDownloading(false);
+ self.setDownloaded(true);
+ self.setProgress(1);
+ self.setCurrentSrc(fileLoader.getPreloadedURL(self.src));
+ return true;
+ }
+ return false;
+ },
+
+ setObject3DLoaded(value) {
+ self.object3dLoaded = value;
+ },
+
+ setProgress(progress) {
+ self.progress = clamp(progress, 0, 100);
+ },
+
+ setDownloading(downloading) {
+ self.downloading = downloading;
+ },
+
+ setDownloaded(downloaded) {
+ self.downloaded = downloaded;
+ },
+
+ setCurrentSrc(src) {
+ self.currentSrc = src;
+ },
+
+ setError() {
+ self.error = true;
+ },
+})).actions(self => ({
+ setRotation(angle) {
+ self.rotation = angle;
+ },
+
+ setNaturalWidth(width) {
+ self.naturalWidth = width;
+ },
+
+ setNaturalHeight(height) {
+ self.naturalHeight = height;
+ },
+
+ setStageWidth(width) {
+ self.stageWidth = width;
+ },
+
+ setStageHeight(height) {
+ self.stageHeight = height;
+ },
+
+ setStageRatio(ratio) {
+ self.stageRatio = ratio;
+ },
+
+ setContainerWidth(width) {
+ self.containerWidth = width;
+ },
+
+ setContainerHeight(height) {
+ self.containerHeight = height;
+ },
+
+ setStageZoom(zoom) {
+ self.stageZoom = zoom;
+ },
+
+ setStageZoomX(zoom) {
+ self.stageZoomX = zoom;
+ },
+
+ setStageZoomY(zoom) {
+ self.stageZoomY = zoom;
+ },
+
+ setCurrentZoom(zoom) {
+ self.currentZoom = zoom;
+ },
+
+ setZoomScale(zoomScale) {
+ self.zoomScale = zoomScale;
+ },
+
+ setZoomingPositionX(x) {
+ self.zoomingPositionX = x;
+ },
+
+ setZoomingPositionY(y) {
+ self.zoomingPositionY = y;
+ },
+
+ setBrightnessGrade(grade) {
+ self.brightnessGrade = grade;
+ },
+
+ setContrastGrade(grade) {
+ self.contrastGrade = grade;
+ },
+}));
diff --git a/src/tags/object/Object3D/Object3DEntityMixin.js b/src/tags/object/Object3D/Object3DEntityMixin.js
new file mode 100644
index 0000000000..ac137494a9
--- /dev/null
+++ b/src/tags/object/Object3D/Object3DEntityMixin.js
@@ -0,0 +1,167 @@
+import { isAlive, types } from 'mobx-state-tree';
+import { Object3DEntity } from './Object3DEntity';
+
+export const Object3DEntityMixin = types
+ .model({
+ currentObject3DEntity: types.maybeNull(types.reference(Object3DEntity)),
+
+ object3dEntities: types.optional(types.array(Object3DEntity), []),
+ })
+ .actions(self => {
+ return {
+ beforeDestroy() {
+ self.currentObject3DEntity = null;
+ },
+ };
+ })
+ .views(self => ({
+ get maxItemIndex() {
+ return self.object3dEntities.length - 1;
+ },
+
+ get object3dIsLoaded() {
+ const object3dEntity = self.currentObject3DEntity;
+
+ return (
+ !object3dEntity.downloading &&
+ !object3dEntity.error &&
+ object3dEntity.downloaded &&
+ object3dEntity.object3dLoaded
+ );
+ },
+ get rotation() {
+ if (!isAlive(self)) {
+ return void 0;
+ }
+ return self.currentObject3DEntity?.rotation;
+ },
+ set rotation(value) {
+ self.currentObject3DEntity?.setRotation(value);
+ },
+
+ get naturalWidth() {
+ return self.currentObject3DEntity?.naturalWidth;
+ },
+ set naturalWidth(value) {
+ self.currentObject3DEntity?.setNaturalWidth(value);
+ },
+
+ get naturalHeight() {
+ return self.currentObject3DEntity?.naturalHeight;
+ },
+ set naturalHeight(value) {
+ self.currentObject3DEntity?.setNaturalHeight(value);
+ },
+
+ get stageWidth() {
+ return self.currentObject3DEntity?.stageWidth;
+ },
+ set stageWidth(value) {
+ self.currentObject3DEntity?.setStageWidth(value);
+ },
+
+ get stageHeight() {
+ return self.currentObject3DEntity?.stageHeight;
+ },
+ set stageHeight(value) {
+ self.currentObject3DEntity?.setStageHeight(value);
+ },
+
+ get stageRatio() {
+ return self.currentObject3DEntity?.stageRatio;
+ },
+ set stageRatio(value) {
+ self.currentObject3DEntity?.setStageRatio(value);
+ },
+
+ get containerWidth() {
+ return self.currentObject3DEntity?.containerWidth;
+ },
+ set containerWidth(value) {
+ self.currentObject3DEntity?.setContainerWidth(value);
+ },
+
+ get containerHeight() {
+ return self.currentObject3DEntity?.containerHeight;
+ },
+ set containerHeight(value) {
+ self.currentObject3DEntity?.setContainerHeight(value);
+ },
+
+ get stageZoom() {
+ return self.currentObject3DEntity?.stageZoom;
+ },
+ set stageZoom(value) {
+ self.currentObject3DEntity?.setStageZoom(value);
+ },
+
+ get stageZoomX() {
+ return self.currentObject3DEntity?.stageZoomX;
+ },
+ set stageZoomX(value) {
+ self.currentObject3DEntity?.setStageZoomX(value);
+ },
+
+ get stageZoomY() {
+ return self.currentObject3DEntity?.stageZoomY;
+ },
+ set stageZoomY(value) {
+ self.currentObject3DEntity?.setStageZoomY(value);
+ },
+
+ get currentZoom() {
+ return self.currentObject3DEntity?.currentZoom;
+ },
+ set currentZoom(value) {
+ self.currentObject3DEntity?.setCurrentZoom(value);
+ },
+
+ get zoomScale() {
+ if (!isAlive(self)) {
+ return void 0;
+ }
+ return self.currentObject3DEntity?.zoomScale;
+ },
+ set zoomScale(value) {
+ self.currentObject3DEntity?.setZoomScale(value);
+ },
+
+ get zoomingPositionX() {
+ if (!isAlive(self)) {
+ return void 0;
+ }
+ return self.currentObject3DEntity?.zoomingPositionX;
+ },
+ set zoomingPositionX(value) {
+ self.currentObject3DEntity?.setZoomingPositionX(value);
+ },
+
+ get zoomingPositionY() {
+ if (!isAlive(self)) {
+ return null;
+ }
+ return self.currentObject3DEntity?.zoomingPositionY;
+ },
+ set zoomingPositionY(value) {
+ self.currentObject3DEntity?.setZoomingPositionY(value);
+ },
+
+ get brightnessGrade() {
+ return self.currentObject3DEntity?.brightnessGrade;
+ },
+ set brightnessGrade(value) {
+ self.currentObject3DEntity?.setBrightnessGrade(value);
+ },
+
+ get contrastGrade() {
+ return self.currentObject3DEntity?.contrastGrade;
+ },
+ set contrastGrade(value) {
+ self.currentObject3DEntity?.setContrastGrade(value);
+ },
+
+ findObject3DEntity(index) {
+ index = index ?? 0;
+ return self.object3dEntities.find(entity => entity.index === index);
+ },
+ }));
diff --git a/src/tags/object/Object3D/Object3DSelection.js b/src/tags/object/Object3D/Object3DSelection.js
new file mode 100644
index 0000000000..afae1f0441
--- /dev/null
+++ b/src/tags/object/Object3D/Object3DSelection.js
@@ -0,0 +1,131 @@
+import { getParent, types } from 'mobx-state-tree';
+import { Object3DSelectionPoint } from './Object3DSelectionPoint';
+import { FF_DEV_3793, isFF } from '../../../utils/feature-flags';
+import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH } from '../../../components/Object3DView/Object3D';
+
+export const Object3DSelection = types.model({
+ start: types.maybeNull(Object3DSelectionPoint),
+ end: types.maybeNull(Object3DSelectionPoint),
+}).views(self => {
+ return {
+ get obj() {
+ return getParent(self);
+ },
+ get annotation() {
+ return self.obj.annotation;
+ },
+ get highlightedNodeExists() {
+ return !!self.annotation.highlightedNode;
+ },
+ get isActive() {
+ return self.start && self.end;
+ },
+ get x() {
+ return Math.min((self.start.x * self.scale), (self.end.x * self.scale));
+ },
+ get y() {
+ return Math.min((self.start.y * self.scale), (self.end.y * self.scale));
+ },
+ get width() {
+ return Math.abs((self.end.x * self.scale) - (self.start.x * self.scale));
+ },
+ get height() {
+ return Math.abs((self.end.y * self.scale) - (self.start.y * self.scale));
+ },
+ get scale() {
+ return self.obj.zoomScale;
+ },
+ get bbox() {
+ const { start, end } = self;
+
+ return self.isActive ? {
+ left: Math.min(start.x, end.x),
+ top: Math.min(start.y, end.y),
+ right: Math.max(start.x, end.x),
+ bottom: Math.max(start.y, end.y),
+ } : null;
+ },
+ get onCanvasBbox() {
+ if (!self.isActive) return null;
+
+ const { start, end } = self;
+
+ return {
+ left: self.obj.internalToCanvasX(Math.min(start.x, end.x)),
+ top: self.obj.internalToCanvasY(Math.min(start.y, end.y)),
+ right: self.obj.internalToCanvasX(Math.max(start.x, end.x)),
+ bottom: self.obj.internalToCanvasY(Math.max(start.y, end.y)),
+ };
+ },
+ get onCanvasRect() {
+ if (!isFF(FF_DEV_3793)) return self;
+
+ if (!self.isActive) return null;
+
+ const bbox = self.onCanvasBbox;
+
+ return {
+ x: bbox.left,
+ y: bbox.top,
+ width: bbox.right - bbox.left,
+ height: bbox.bottom - bbox.top,
+ };
+ },
+ includesBbox(bbox) {
+ if (!self.isActive || !bbox) return false;
+ const isLeftOf = self.bbox.left <= bbox.left;
+ const isAbove = self.bbox.top <= bbox.top;
+ const isRightOf = self.bbox.right >= bbox.right;
+ const isBelow = self.bbox.bottom >= bbox.bottom;
+
+ return isLeftOf && isAbove && isRightOf && isBelow;
+ },
+ intersectsBbox(bbox) {
+ if (!self.isActive || !bbox) return false;
+ const selfCenterX = (self.bbox.left + self.bbox.right) / 2;
+ const selfCenterY = (self.bbox.top + self.bbox.bottom) / 2;
+ const selfWidth = self.bbox.right - self.bbox.left;
+ const selfHeight = self.bbox.bottom - self.bbox.top;
+ const targetCenterX = (bbox.left + bbox.right) / 2;
+ const targetCenterY = (bbox.top + bbox.bottom) / 2;
+ const targetWidth = bbox.right - bbox.left;
+ const targetHeight = bbox.bottom - bbox.top;
+
+ return (Math.abs(selfCenterX - targetCenterX) * 2 < (selfWidth + targetWidth)) &&
+ (Math.abs(selfCenterY - targetCenterY) * 2 < (selfHeight + targetHeight));
+ },
+ get selectionBorders() {
+ if (self.isActive || !self.obj.selectedRegions.length) return null;
+
+ const initial = isFF(FF_DEV_3793)
+ ? { left: RELATIVE_STAGE_WIDTH, top: RELATIVE_STAGE_HEIGHT, right: 0, bottom: 0 }
+ : { left: self.obj.stageWidth, top: self.obj.stageHeight, right: 0, bottom: 0 };
+ const bbox = self.obj.selectedRegions.reduce((borders, region) => {
+ return region.bboxCoords ? {
+ left: Math.min(borders.left, region.bboxCoords.left),
+ top: Math.min(borders.top,region.bboxCoords.top),
+ right: Math.max(borders.right, region.bboxCoords.right),
+ bottom: Math.max(borders.bottom, region.bboxCoords.bottom),
+ } : borders;
+ }, initial);
+
+ if (!isFF(FF_DEV_3793)) return bbox;
+
+ return {
+ left: self.obj.internalToCanvasX(bbox.left),
+ top: self.obj.internalToCanvasY(bbox.top),
+ right: self.obj.internalToCanvasX(bbox.right),
+ bottom: self.obj.internalToCanvasY(bbox.bottom),
+ };
+ },
+ };
+}).actions(self => {
+ return {
+ setStart(point) {
+ self.start = point;
+ },
+ setEnd(point) {
+ self.end = point;
+ },
+ };
+});
diff --git a/src/tags/object/Object3D/Object3DSelectionPoint.js b/src/tags/object/Object3D/Object3DSelectionPoint.js
new file mode 100644
index 0000000000..dac85aabf3
--- /dev/null
+++ b/src/tags/object/Object3D/Object3DSelectionPoint.js
@@ -0,0 +1,6 @@
+import { types } from 'mobx-state-tree';
+
+export const Object3DSelectionPoint = types.model({
+ x: types.number,
+ y: types.number,
+});
diff --git a/src/tags/object/Object3D/index.js b/src/tags/object/Object3D/index.js
new file mode 100644
index 0000000000..1822688d52
--- /dev/null
+++ b/src/tags/object/Object3D/index.js
@@ -0,0 +1 @@
+export { Object3DModel, HtxObject3D } from './Object3D';
diff --git a/src/tags/object/PagedView.js b/src/tags/object/PagedView.js
index 7fcdf5d8bb..de57a06e23 100644
--- a/src/tags/object/PagedView.js
+++ b/src/tags/object/PagedView.js
@@ -27,12 +27,14 @@ const Model = types.model({
'number',
'rating',
'ranker',
+ 'cube',
'rectangle',
'ellipse',
'polygon',
'keypoint',
'brush',
'magicwand',
+ 'cubelabels',
'rectanglelabels',
'ellipselabels',
'polygonlabels',
@@ -62,6 +64,7 @@ const Model = types.model({
'paragraphlabels',
'video',
'videorectangle',
+ 'object3d',
]),
});
diff --git a/src/tags/object/index.js b/src/tags/object/index.js
index a0497f6616..26700e9204 100644
--- a/src/tags/object/index.js
+++ b/src/tags/object/index.js
@@ -7,6 +7,7 @@ import { TimeSeriesModel } from './TimeSeries';
import { PagedViewModel } from './PagedView';
import { VideoModel } from './Video';
import { ListModel } from './List';
+import { Object3DModel } from './Object3D';
// stub files to keep docs of these tags
import './HyperText';
@@ -21,5 +22,6 @@ export {
VideoModel,
TableModel,
PagedViewModel,
- ListModel
+ ListModel,
+ Object3DModel
};
diff --git a/src/tags/visual/Collapse.js b/src/tags/visual/Collapse.js
index b350fb5e7d..5e0d519db3 100644
--- a/src/tags/visual/Collapse.js
+++ b/src/tags/visual/Collapse.js
@@ -40,11 +40,13 @@ const PanelModel = types.model({
'choice',
'rating',
'ranker',
+ 'cube',
'rectangle',
'ellipse',
'polygon',
'keypoint',
'brush',
+ 'cubelabels',
'rectanglelabels',
'ellipselabels',
'polygonlabels',
@@ -68,6 +70,7 @@ const PanelModel = types.model({
'timeserieslabels',
'paragraphs',
'paragraphlabels',
+ 'object3d',
]),
});
diff --git a/src/tags/visual/View.js b/src/tags/visual/View.js
index 52cbd32c6e..e80918f4b4 100644
--- a/src/tags/visual/View.js
+++ b/src/tags/visual/View.js
@@ -57,12 +57,14 @@ const Model = types
'number',
'rating',
'ranker',
+ 'cube',
'rectangle',
'ellipse',
'polygon',
'keypoint',
'brush',
'magicwand',
+ 'cubelabels',
'rectanglelabels',
'ellipselabels',
'polygonlabels',
@@ -93,6 +95,7 @@ const Model = types
'video',
'videorectangle',
'ranker',
+ 'object3d',
]),
});
diff --git a/src/tools/Cube.js b/src/tools/Cube.js
new file mode 100644
index 0000000000..13b09d3ea5
--- /dev/null
+++ b/src/tools/Cube.js
@@ -0,0 +1,107 @@
+import { types } from 'mobx-state-tree';
+
+import BaseTool, { DEFAULT_DIMENSIONS } from './Base';
+import ToolMixin from '../mixins/Tool';
+import { CubeDrawingTool } from '../mixins/DrawingTool';
+import { AnnotationMixin } from '../mixins/AnnotationMixin';
+import { NodeViews } from '../components/Node/Node';
+import { FF_DEV_3793, isFF } from '../utils/feature-flags';
+
+const _BaseNPointTool = types
+ .model('BaseNTool', {
+ group: 'segmentation',
+ smart: true,
+ shortcut: 'R',
+ })
+ .views(self => {
+ const Super = {
+ createRegionOptions: self.createRegionOptions,
+ isIncorrectControl: self.isIncorrectControl,
+ isIncorrectLabel: self.isIncorrectLabel,
+ };
+
+ return {
+ get getActivePolygon() {
+ const poly = self.currentArea;
+
+ if (poly && poly.closed) return null;
+ if (poly === undefined) return null;
+ if (poly && poly.type !== 'rectangleregion') return null;
+
+ return poly;
+ },
+
+ get tagTypes() {
+ return {
+ stateTypes: 'rectanglelabels',
+ controlTagTypes: ['rectanglelabels', 'rectangle'],
+ };
+ },
+ get defaultDimensions() {
+ return DEFAULT_DIMENSIONS.rect;
+ },
+ createRegionOptions({ x, y }) {
+ return Super.createRegionOptions({
+ x,
+ y,
+ height: isFF(FF_DEV_3793) ? self.obj.canvasToInternalY(1) : 1,
+ width: isFF(FF_DEV_3793) ? self.obj.canvasToInternalX(1) : 1,
+ });
+ },
+
+ isIncorrectControl() {
+ return Super.isIncorrectControl() && self.current() === null;
+ },
+ isIncorrectLabel() {
+ return !self.current() && Super.isIncorrectLabel();
+ },
+ canStart() {
+ return self.current() === null && !self.annotation.isReadOnly();
+ },
+
+ current() {
+ return self.getActivePolygon;
+ },
+ };
+ })
+ .actions(self => ({
+ beforeCommitDrawing() {
+ const s = self.getActiveShape;
+
+ return s.width > self.MIN_SIZE.X && s.height * self.MIN_SIZE.Y;
+ },
+ }));
+
+const _Tool = types
+ .model('RectangleTool', {
+ shortcut: 'R',
+ })
+ .views(self => ({
+ get viewTooltip() {
+ return 'Rectangle';
+ },
+ get iconComponent() {
+ return self.dynamic
+ ? NodeViews.RectRegionModel.altIcon
+ : NodeViews.RectRegionModel.icon;
+ },
+ }));
+
+const _Tool3Point = types
+ .model('Rectangle3PointTool', {
+ shortcut: 'shift+R',
+ })
+ .views(self => ({
+ get viewTooltip() {
+ return '3 Point Rectangle';
+ },
+ get iconComponent() {
+ return self.dynamic
+ ? NodeViews.Rect3PointRegionModel.altIcon
+ : NodeViews.Rect3PointRegionModel.icon;
+ },
+ }));
+
+const Rect = types.compose(_Tool.name, ToolMixin, BaseTool, TwoPointsDrawingTool, _BaseNPointTool, _Tool, AnnotationMixin);
+
+export { Rect };
diff --git a/yarn.lock b/yarn.lock
index eff97bbe9a..792479c6ce 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4533,6 +4533,7 @@ ansi-escapes@^3.0.0:
ansi-escapes@^4.2.1:
version "4.3.2"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
+ integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
dependencies:
type-fest "^0.21.3"
@@ -5178,6 +5179,7 @@ camelcase@^5.3.1:
camelcase@^6.2.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+ integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
caniuse-api@^3.0.0:
version "3.0.0"
@@ -5688,6 +5690,7 @@ css-loader@^6.7.3:
css-minimizer-webpack-plugin@^3.0.2:
version "3.4.1"
resolved "https://registry.yarnpkg.com/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz#ab78f781ced9181992fe7b6e4f3422e76429878f"
+ integrity sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==
dependencies:
cssnano "^5.0.6"
jest-worker "^27.0.2"
@@ -9602,6 +9605,7 @@ parse5-htmlparser2-tree-adapter@^6.0.1:
parse5@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
+ integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
parse5@^7.0.0, parse5@^7.1.1:
version "7.1.2"
@@ -10208,6 +10212,7 @@ promise-polyfill@^8.1.3:
prompts@^2.0.1:
version "2.4.2"
resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069"
+ integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==
dependencies:
kleur "^3.0.3"
sisteransi "^1.0.5"
@@ -10677,6 +10682,7 @@ react-beautiful-dnd@^13.1.1:
react-dom@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
+ integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@@ -10801,6 +10807,7 @@ react-window@^1.8.6:
react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
+ integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@@ -12542,6 +12549,7 @@ which-typed-array@^1.1.9:
which@^1.2.9:
version "1.3.1"
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
+ integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
dependencies:
isexe "^2.0.0"