Compare commits
No commits in common. "main" and "electrobun-tabs" have entirely different histories.
main
...
electrobun
@ -484,11 +484,6 @@ code {
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spotlight-tooltip.ant-card,
|
|
||||||
.spotlight-tooltip .ant-card {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-popover:has(.spotlight-tooltip) {
|
.ant-popover:has(.spotlight-tooltip) {
|
||||||
z-index: 1051;
|
z-index: 1051;
|
||||||
}
|
}
|
||||||
@ -2605,14 +2600,6 @@ body.objectKanbanColumnResizing * {
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewStatsOverlay {
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
bottom: 12px;
|
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewZoomCard {
|
.previewZoomCard {
|
||||||
background-color: color-mix(
|
background-color: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
@ -4213,197 +4200,12 @@ body.objectKanbanColumnResizing * {
|
|||||||
stroke-width: 12px;
|
stroke-width: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-navigation-activity-cluster {
|
|
||||||
gap: 0;
|
|
||||||
transition: gap 0.3s cubic-bezier(0.2, 0, 0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-navigation-activity-cluster:has(.live-activities-open) {
|
|
||||||
gap: var(--dashboard-navigation-gap, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activities {
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 12px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
transition:
|
|
||||||
width 0.3s cubic-bezier(0.2, 0, 0, 1),
|
|
||||||
opacity 0.3s cubic-bezier(0.2, 0, 0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activities-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
height: 100%;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-slot {
|
|
||||||
overflow: hidden;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
transition: width 0.3s cubic-bezier(0.2, 0, 0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1;
|
|
||||||
container-type: inline-size;
|
|
||||||
background: color-mix(in srgb, var(--live-activity-color) 20%, transparent);
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity:hover {
|
|
||||||
background: color-mix(in srgb, var(--live-activity-color) 35%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-progress,
|
|
||||||
.live-activity-flash {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-flash {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-progress {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-progress-fill {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0.22;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity:hover .live-activity-progress-fill {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-progress-wave {
|
|
||||||
position: absolute;
|
|
||||||
inset-block: 0;
|
|
||||||
inset-inline-start: -80cqw;
|
|
||||||
width: 240cqw;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(255, 255, 255, 0) 0,
|
|
||||||
rgba(255, 255, 255, 0.05) 40cqw,
|
|
||||||
rgba(255, 255, 255, 0) 80cqw
|
|
||||||
);
|
|
||||||
animation: live-activity-progress-wave 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes live-activity-progress-wave {
|
|
||||||
0% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(calc(100% / 3));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-icon {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes live-activity-flash {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: var(--live-activity-flash-from);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: var(--live-activity-flash-to);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-flash {
|
|
||||||
--live-activity-flash-from: 0;
|
|
||||||
--live-activity-flash-to: 0.2;
|
|
||||||
animation: live-activity-flash 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity:hover .live-activity-flash {
|
|
||||||
--live-activity-flash-from: 0.12;
|
|
||||||
--live-activity-flash-to: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-favicon-source {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
transform: translateX(-120vw);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-favicon-source .live-activity {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--live-activity-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
box-shadow: none;
|
|
||||||
cursor: default;
|
|
||||||
background: color-mix(
|
|
||||||
in srgb,
|
|
||||||
var(--live-activity-color) 45%,
|
|
||||||
var(--color-background)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-favicon-source .live-activity-progress-fill {
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-favicon-source .live-activity-flash {
|
|
||||||
--live-activity-flash-from: 0.55;
|
|
||||||
--live-activity-flash-to: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-activity-favicon-source .live-activity-progress-wave {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.printerQueue.ant-steps.ant-steps-vertical
|
.printerQueue.ant-steps.ant-steps-vertical
|
||||||
> .ant-steps-item:last-child
|
> .ant-steps-item:last-child
|
||||||
.ant-steps-item-content {
|
.ant-steps-item-content {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.printerQueue.ant-steps .ant-steps-item-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
padding-inline-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-footer-navigation.ant-layout-footer {
|
.mobile-footer-navigation.ant-layout-footer {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
5
bun.lock
5
bun.lock
@ -57,6 +57,7 @@
|
|||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
|
"gcode-preview": "3.0.0-alpha.6",
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^1.0.0",
|
||||||
"keycloak-js": "^26.2.0",
|
"keycloak-js": "^26.2.0",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
@ -1349,6 +1350,8 @@
|
|||||||
|
|
||||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||||
|
|
||||||
|
"gcode-preview": ["gcode-preview@3.0.0-alpha.6", "", { "dependencies": { "lil-gui": "^0.20.0", "three": ">=0.166.0 <0.186.0" } }, "sha512-pLgIhufGNdo6ChTrFeuRsjvl/eg2rH1LK4E+qUaLsL0BBWIE/phry2zfxuXHxBSdAKA5+cbXGpCmp/RsxHgVUQ=="],
|
||||||
|
|
||||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||||
|
|
||||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
@ -1571,6 +1574,8 @@
|
|||||||
|
|
||||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||||
|
|
||||||
|
"lil-gui": ["lil-gui@0.20.0", "", {}, "sha512-k7Ipr0ztqslMA2XvM5z5ZaWhxQtnEOwJBfI/hmSuRh6q4iMG9L0boqqrnZSzBR1jzyJ28OMl47l65ILzRe1TdA=="],
|
||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "2.1.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "2.1.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||||
|
|||||||
@ -513,104 +513,3 @@ extern "C" bool setNativeWindowDragRegion(void *windowPtr, double x,
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
@interface ElectrobunDockProgressView : NSView
|
|
||||||
@property(nonatomic) double progress;
|
|
||||||
@property(nonatomic) int state;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation ElectrobunDockProgressView
|
|
||||||
@synthesize progress = _progress;
|
|
||||||
@synthesize state = _state;
|
|
||||||
|
|
||||||
- (NSColor *)fillColor {
|
|
||||||
switch (self.state) {
|
|
||||||
case 1:
|
|
||||||
return [NSColor systemRedColor];
|
|
||||||
case 2:
|
|
||||||
return [NSColor systemYellowColor];
|
|
||||||
default:
|
|
||||||
if (@available(macOS 10.14, *)) {
|
|
||||||
return [NSColor controlAccentColor];
|
|
||||||
}
|
|
||||||
return [NSColor systemBlueColor];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)drawRect:(NSRect)dirtyRect {
|
|
||||||
(void)dirtyRect;
|
|
||||||
|
|
||||||
NSRect bounds = [self bounds];
|
|
||||||
[[NSApp applicationIconImage] drawInRect:bounds
|
|
||||||
fromRect:NSZeroRect
|
|
||||||
operation:NSCompositingOperationSourceOver
|
|
||||||
fraction:1.0];
|
|
||||||
|
|
||||||
CGFloat inset = round(bounds.size.width * 0.14);
|
|
||||||
CGFloat height = round(bounds.size.height * 0.11);
|
|
||||||
NSRect track =
|
|
||||||
NSMakeRect(NSMinX(bounds) + inset, NSMinY(bounds) + round(height * 0.9),
|
|
||||||
bounds.size.width - inset * 2.0, height);
|
|
||||||
CGFloat radius = height / 2.0;
|
|
||||||
|
|
||||||
NSBezierPath *trackPath = [NSBezierPath bezierPathWithRoundedRect:track
|
|
||||||
xRadius:radius
|
|
||||||
yRadius:radius];
|
|
||||||
[[NSColor colorWithWhite:0.0 alpha:0.55] setFill];
|
|
||||||
[trackPath fill];
|
|
||||||
|
|
||||||
double fraction = MIN(1.0, MAX(0.0, self.progress));
|
|
||||||
if (fraction > 0.0) {
|
|
||||||
NSRect fill = NSInsetRect(track, 1.5, 1.5);
|
|
||||||
CGFloat fillRadius = fill.size.height / 2.0;
|
|
||||||
fill.size.width = MAX(fill.size.height, fill.size.width * fraction);
|
|
||||||
[[self fillColor] setFill];
|
|
||||||
[[NSBezierPath bezierPathWithRoundedRect:fill
|
|
||||||
xRadius:fillRadius
|
|
||||||
yRadius:fillRadius] fill];
|
|
||||||
}
|
|
||||||
|
|
||||||
[[NSColor colorWithWhite:1.0 alpha:0.85] setStroke];
|
|
||||||
NSBezierPath *outline =
|
|
||||||
[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(track, 0.5, 0.5)
|
|
||||||
xRadius:radius - 0.5
|
|
||||||
yRadius:radius - 0.5];
|
|
||||||
outline.lineWidth = 1.0;
|
|
||||||
[outline stroke];
|
|
||||||
}
|
|
||||||
@end
|
|
||||||
|
|
||||||
// fraction < 0 removes the progress bar; state: 0 normal, 1 error, 2 paused.
|
|
||||||
extern "C" bool setDockProgress(double fraction, int state) {
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
NSDockTile *dockTile = [NSApp dockTile];
|
|
||||||
if (dockTile == nil) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fraction < 0.0) {
|
|
||||||
if ([dockTile contentView] != nil) {
|
|
||||||
[dockTile setContentView:nil];
|
|
||||||
[dockTile display];
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ElectrobunDockProgressView *view = nil;
|
|
||||||
if ([[dockTile contentView]
|
|
||||||
isKindOfClass:[ElectrobunDockProgressView class]]) {
|
|
||||||
view = (ElectrobunDockProgressView *)[dockTile contentView];
|
|
||||||
} else {
|
|
||||||
NSSize size = [dockTile size];
|
|
||||||
view = [[ElectrobunDockProgressView alloc]
|
|
||||||
initWithFrame:NSMakeRect(0, 0, size.width, size.height)];
|
|
||||||
[dockTile setContentView:view];
|
|
||||||
}
|
|
||||||
|
|
||||||
view.progress = fraction;
|
|
||||||
view.state = state;
|
|
||||||
[dockTile display];
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -61,6 +61,7 @@
|
|||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
|
"gcode-preview": "3.0.0-alpha.6",
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^1.0.0",
|
||||||
"keycloak-js": "^26.2.0",
|
"keycloak-js": "^26.2.0",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -172,6 +172,9 @@ importers:
|
|||||||
electron-store:
|
electron-store:
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.0.2
|
version: 11.0.2
|
||||||
|
gcode-preview:
|
||||||
|
specifier: 3.0.0-alpha.6
|
||||||
|
version: 3.0.0-alpha.6
|
||||||
json-schema-traverse:
|
json-schema-traverse:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@ -3368,6 +3371,9 @@ packages:
|
|||||||
functions-have-names@1.2.3:
|
functions-have-names@1.2.3:
|
||||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||||
|
|
||||||
|
gcode-preview@3.0.0-alpha.6:
|
||||||
|
resolution: {integrity: sha512-pLgIhufGNdo6ChTrFeuRsjvl/eg2rH1LK4E+qUaLsL0BBWIE/phry2zfxuXHxBSdAKA5+cbXGpCmp/RsxHgVUQ==}
|
||||||
|
|
||||||
generator-function@2.0.1:
|
generator-function@2.0.1:
|
||||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -3801,6 +3807,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
lil-gui@0.20.0:
|
||||||
|
resolution: {integrity: sha512-k7Ipr0ztqslMA2XvM5z5ZaWhxQtnEOwJBfI/hmSuRh6q4iMG9L0boqqrnZSzBR1jzyJ28OMl47l65ILzRe1TdA==}
|
||||||
|
|
||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||||
|
|
||||||
@ -9264,6 +9273,11 @@ snapshots:
|
|||||||
|
|
||||||
functions-have-names@1.2.3: {}
|
functions-have-names@1.2.3: {}
|
||||||
|
|
||||||
|
gcode-preview@3.0.0-alpha.6:
|
||||||
|
dependencies:
|
||||||
|
lil-gui: 0.20.0
|
||||||
|
three: 0.179.1
|
||||||
|
|
||||||
generator-function@2.0.1: {}
|
generator-function@2.0.1: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
@ -9731,6 +9745,8 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
lil-gui@0.20.0: {}
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|
||||||
linkify-it@5.0.0:
|
linkify-it@5.0.0:
|
||||||
|
|||||||
@ -92,10 +92,10 @@ const AppContent = () => {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<TableStateProvider>
|
<TableStateProvider>
|
||||||
<AppUpdateProvider>
|
<AppUpdateProvider>
|
||||||
<ActionsProvider>
|
<NotificationProvider>
|
||||||
<NotificationProvider>
|
|
||||||
<SpotlightProvider>
|
<SpotlightProvider>
|
||||||
<ActionsModalProvider>
|
<ActionsModalProvider>
|
||||||
|
<ActionsProvider>
|
||||||
<DashboardObjectToolsProvider>
|
<DashboardObjectToolsProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
@ -173,10 +173,10 @@ const AppContent = () => {
|
|||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</DashboardObjectToolsProvider>
|
</DashboardObjectToolsProvider>
|
||||||
|
</ActionsProvider>
|
||||||
</ActionsModalProvider>
|
</ActionsModalProvider>
|
||||||
</SpotlightProvider>
|
</SpotlightProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</ActionsProvider>
|
|
||||||
</AppUpdateProvider>
|
</AppUpdateProvider>
|
||||||
</TableStateProvider>
|
</TableStateProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,164 +0,0 @@
|
|||||||
import * as THREE from 'three'
|
|
||||||
|
|
||||||
export const DEFAULT_BUILD_VOLUME = Object.freeze({
|
|
||||||
minX: 0,
|
|
||||||
maxX: 250,
|
|
||||||
minY: 0,
|
|
||||||
maxY: 220,
|
|
||||||
maxZ: 150
|
|
||||||
})
|
|
||||||
|
|
||||||
export function normalizeBuildVolume(volume) {
|
|
||||||
const v = { ...DEFAULT_BUILD_VOLUME, ...(volume || {}) }
|
|
||||||
if (!(v.maxX > v.minX)) {
|
|
||||||
v.minX = DEFAULT_BUILD_VOLUME.minX
|
|
||||||
v.maxX = DEFAULT_BUILD_VOLUME.maxX
|
|
||||||
}
|
|
||||||
if (!(v.maxY > v.minY)) {
|
|
||||||
v.minY = DEFAULT_BUILD_VOLUME.minY
|
|
||||||
v.maxY = DEFAULT_BUILD_VOLUME.maxY
|
|
||||||
}
|
|
||||||
if (!(v.maxZ > 0)) v.maxZ = DEFAULT_BUILD_VOLUME.maxZ
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grows the configured volume so a model that pokes outside the bed (or a
|
|
||||||
// file with no bed metadata) is still framed.
|
|
||||||
export function expandVolumeToBounds(volume, bounds) {
|
|
||||||
if (!bounds) return volume
|
|
||||||
const pad = 5
|
|
||||||
return {
|
|
||||||
minX: Math.min(volume.minX, Math.floor(bounds.minX - pad)),
|
|
||||||
maxX: Math.max(volume.maxX, Math.ceil(bounds.maxX + pad)),
|
|
||||||
minY: Math.min(volume.minY, Math.floor(bounds.minY - pad)),
|
|
||||||
maxY: Math.max(volume.maxY, Math.ceil(bounds.maxY + pad)),
|
|
||||||
maxZ: Math.max(volume.maxZ, Math.ceil(bounds.maxZ + pad))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grid, outline and axis indicator for the printer bed. Z is up.
|
|
||||||
export default class Bed {
|
|
||||||
constructor() {
|
|
||||||
this.group = new THREE.Group()
|
|
||||||
this.minorMaterial = new THREE.LineBasicMaterial({
|
|
||||||
color: 0x888888,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.18
|
|
||||||
})
|
|
||||||
this.majorMaterial = new THREE.LineBasicMaterial({
|
|
||||||
color: 0x888888,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.45
|
|
||||||
})
|
|
||||||
this.outlineMaterial = new THREE.LineBasicMaterial({
|
|
||||||
color: 0x888888,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.9
|
|
||||||
})
|
|
||||||
this.axisMaterial = new THREE.LineBasicMaterial({ vertexColors: true })
|
|
||||||
this.objects = []
|
|
||||||
this.volume = normalizeBuildVolume(null)
|
|
||||||
this.setVolume(this.volume)
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
for (const object of this.objects) {
|
|
||||||
this.group.remove(object)
|
|
||||||
object.geometry.dispose()
|
|
||||||
}
|
|
||||||
this.objects = []
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(volume) {
|
|
||||||
this.volume = normalizeBuildVolume(volume)
|
|
||||||
this.clear()
|
|
||||||
const { minX, maxX, minY, maxY } = this.volume
|
|
||||||
const minor = []
|
|
||||||
const major = []
|
|
||||||
const push = (list, x0, y0, x1, y1) => list.push(x0, y0, 0, x1, y1, 0)
|
|
||||||
|
|
||||||
const step = 10
|
|
||||||
const majorEvery = 50
|
|
||||||
for (let x = Math.ceil(minX / step) * step; x <= maxX; x += step) {
|
|
||||||
const list = x % majorEvery === 0 ? major : minor
|
|
||||||
push(list, x, minY, x, maxY)
|
|
||||||
}
|
|
||||||
for (let y = Math.ceil(minY / step) * step; y <= maxY; y += step) {
|
|
||||||
const list = y % majorEvery === 0 ? major : minor
|
|
||||||
push(list, minX, y, maxX, y)
|
|
||||||
}
|
|
||||||
const outline = []
|
|
||||||
push(outline, minX, minY, maxX, minY)
|
|
||||||
push(outline, maxX, minY, maxX, maxY)
|
|
||||||
push(outline, maxX, maxY, minX, maxY)
|
|
||||||
push(outline, minX, maxY, minX, minY)
|
|
||||||
|
|
||||||
const axisLength = Math.max(15, Math.min(40, (maxX - minX) * 0.1))
|
|
||||||
const axes = [
|
|
||||||
minX,
|
|
||||||
minY,
|
|
||||||
0,
|
|
||||||
minX + axisLength,
|
|
||||||
minY,
|
|
||||||
0,
|
|
||||||
minX,
|
|
||||||
minY,
|
|
||||||
0,
|
|
||||||
minX,
|
|
||||||
minY + axisLength,
|
|
||||||
0,
|
|
||||||
minX,
|
|
||||||
minY,
|
|
||||||
0,
|
|
||||||
minX,
|
|
||||||
minY,
|
|
||||||
axisLength
|
|
||||||
]
|
|
||||||
const axisColors = [
|
|
||||||
1, 0.3, 0.3, 1, 0.3, 0.3, 0.3, 1, 0.3, 0.3, 1, 0.3, 0.3, 0.5, 1, 0.3, 0.5,
|
|
||||||
1
|
|
||||||
]
|
|
||||||
|
|
||||||
const add = (positions, material, colors) => {
|
|
||||||
if (!positions.length) return
|
|
||||||
const geometry = new THREE.BufferGeometry()
|
|
||||||
geometry.setAttribute(
|
|
||||||
'position',
|
|
||||||
new THREE.Float32BufferAttribute(positions, 3)
|
|
||||||
)
|
|
||||||
if (colors) {
|
|
||||||
geometry.setAttribute(
|
|
||||||
'color',
|
|
||||||
new THREE.Float32BufferAttribute(colors, 3)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const lines = new THREE.LineSegments(geometry, material)
|
|
||||||
lines.renderOrder = -1
|
|
||||||
this.group.add(lines)
|
|
||||||
this.objects.push(lines)
|
|
||||||
}
|
|
||||||
add(minor, this.minorMaterial)
|
|
||||||
add(major, this.majorMaterial)
|
|
||||||
add(outline, this.outlineMaterial)
|
|
||||||
add(axes, this.axisMaterial, axisColors)
|
|
||||||
}
|
|
||||||
|
|
||||||
setColor(hex) {
|
|
||||||
this.minorMaterial.color.set(hex)
|
|
||||||
this.majorMaterial.color.set(hex)
|
|
||||||
this.outlineMaterial.color.set(hex)
|
|
||||||
}
|
|
||||||
|
|
||||||
center(out = new THREE.Vector3()) {
|
|
||||||
const { minX, maxX, minY, maxY } = this.volume
|
|
||||||
return out.set((minX + maxX) / 2, (minY + maxY) / 2, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.clear()
|
|
||||||
this.minorMaterial.dispose()
|
|
||||||
this.majorMaterial.dispose()
|
|
||||||
this.outlineMaterial.dispose()
|
|
||||||
this.axisMaterial.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
import * as THREE from 'three'
|
|
||||||
import { MAX_FEATURE_TYPES } from './featureTypes.js'
|
|
||||||
import { RAMP_STOPS } from './colorModes.js'
|
|
||||||
|
|
||||||
export const MAX_TOOLS = 16
|
|
||||||
|
|
||||||
// Uniforms shared by the box (extrusion) and line materials so a single
|
|
||||||
// setter updates both draw calls.
|
|
||||||
export function createSharedUniforms() {
|
|
||||||
const typeColors = []
|
|
||||||
for (let i = 0; i < MAX_FEATURE_TYPES; i++) typeColors.push(new THREE.Color())
|
|
||||||
const toolColors = []
|
|
||||||
for (let i = 0; i < MAX_TOOLS; i++) toolColors.push(new THREE.Color())
|
|
||||||
return {
|
|
||||||
uColorMode: { value: 0 },
|
|
||||||
uRangeMin: { value: 0 },
|
|
||||||
uRangeMax: { value: 1 },
|
|
||||||
uLogScale: { value: 0 },
|
|
||||||
uLayerMin: { value: 0 },
|
|
||||||
uLayerMax: { value: 65535 },
|
|
||||||
uVisibleEnd: { value: 0 },
|
|
||||||
uProgressT: { value: 1 },
|
|
||||||
uTypeColors: { value: typeColors },
|
|
||||||
uToolColors: { value: toolColors },
|
|
||||||
uRamp: { value: RAMP_STOPS.map((c) => new THREE.Color(c)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMON_VERTEX = /* glsl */ `
|
|
||||||
uniform int uColorMode;
|
|
||||||
uniform float uRangeMin;
|
|
||||||
uniform float uRangeMax;
|
|
||||||
uniform int uLogScale;
|
|
||||||
uniform int uTypeMask;
|
|
||||||
uniform float uLayerMin;
|
|
||||||
uniform float uLayerMax;
|
|
||||||
uniform int uVisibleEnd;
|
|
||||||
uniform float uProgressT;
|
|
||||||
uniform vec3 uTypeColors[${MAX_FEATURE_TYPES}];
|
|
||||||
uniform vec3 uToolColors[${MAX_TOOLS}];
|
|
||||||
uniform vec3 uRamp[${RAMP_STOPS.length}];
|
|
||||||
|
|
||||||
in vec3 instanceStart;
|
|
||||||
in vec3 instanceEnd;
|
|
||||||
in float aType;
|
|
||||||
in float aTool;
|
|
||||||
in float aFan;
|
|
||||||
in float aLayer;
|
|
||||||
in float aTemp;
|
|
||||||
in float aWidth;
|
|
||||||
in float aHeight;
|
|
||||||
in float aFeed;
|
|
||||||
in float aAccel;
|
|
||||||
in float aActualSpeed;
|
|
||||||
in float aLayerTime;
|
|
||||||
|
|
||||||
bool segmentVisible(int t) {
|
|
||||||
if (t == 0) return false;
|
|
||||||
if (((uTypeMask >> t) & 1) == 0) return false;
|
|
||||||
if (aLayer < uLayerMin - 0.5 || aLayer > uLayerMax + 0.5) return false;
|
|
||||||
if (gl_InstanceID >= uVisibleEnd) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
float scalarValue(int mode) {
|
|
||||||
switch (mode) {
|
|
||||||
case 1: return aHeight;
|
|
||||||
case 2: return aWidth;
|
|
||||||
case 3: return aFeed;
|
|
||||||
case 4: return aActualSpeed;
|
|
||||||
case 5: return aAccel;
|
|
||||||
case 6: return aFan * (100.0 / 255.0);
|
|
||||||
case 7: return aTemp;
|
|
||||||
case 8: return aWidth * aHeight * aFeed;
|
|
||||||
case 9: return aWidth * aHeight * aActualSpeed;
|
|
||||||
case 10: return aLayerTime;
|
|
||||||
default: return 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vec3 rampColor(float t) {
|
|
||||||
t = clamp(t, 0.0, 1.0) * float(${RAMP_STOPS.length - 1});
|
|
||||||
int i = int(floor(t));
|
|
||||||
if (i >= ${RAMP_STOPS.length - 1}) i = ${RAMP_STOPS.length - 2};
|
|
||||||
return mix(uRamp[i], uRamp[i + 1], t - float(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
vec3 segmentColor(int t) {
|
|
||||||
// Travel, wipe and category mode use the per-type palette.
|
|
||||||
if (uColorMode == 0 || t <= 1 || t == 17) return uTypeColors[t];
|
|
||||||
if (uColorMode == 11) return uToolColors[int(aTool + 0.5) & ${MAX_TOOLS - 1}];
|
|
||||||
float v = scalarValue(uColorMode);
|
|
||||||
float lo = uRangeMin;
|
|
||||||
float hi = uRangeMax;
|
|
||||||
if (uLogScale == 1) {
|
|
||||||
v = log(max(v, 1e-3));
|
|
||||||
lo = log(max(lo, 1e-3));
|
|
||||||
hi = log(max(hi, 1e-3));
|
|
||||||
}
|
|
||||||
float span = hi - lo;
|
|
||||||
float n = span > 1e-6 ? (v - lo) / span : 0.5;
|
|
||||||
return rampColor(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
vec3 segmentEnd() {
|
|
||||||
if (gl_InstanceID == uVisibleEnd - 1) {
|
|
||||||
return mix(instanceStart, instanceEnd, uProgressT);
|
|
||||||
}
|
|
||||||
return instanceEnd;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const BOX_VERTEX = /* glsl */ `
|
|
||||||
${COMMON_VERTEX}
|
|
||||||
out vec3 vNormal;
|
|
||||||
out vec3 vColor;
|
|
||||||
out vec3 vViewPos;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
int t = int(aType + 0.5);
|
|
||||||
if (!segmentVisible(t) || aWidth <= 0.0 || aHeight <= 0.0) {
|
|
||||||
gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
|
|
||||||
vNormal = vec3(0.0, 0.0, 1.0);
|
|
||||||
vColor = vec3(0.0);
|
|
||||||
vViewPos = vec3(0.0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
vec3 start = instanceStart;
|
|
||||||
vec3 end = segmentEnd();
|
|
||||||
vec3 d = end - start;
|
|
||||||
float len = length(d);
|
|
||||||
vec3 f = len > 1e-6 ? d / len : vec3(1.0, 0.0, 0.0);
|
|
||||||
// Build a right-handed (f, side, up) basis so the box keeps its winding;
|
|
||||||
// a mirrored basis would flip the faces and back-face culling would then
|
|
||||||
// discard the outside of every line, exposing its interior.
|
|
||||||
vec3 side = cross(vec3(0.0, 0.0, 1.0), f);
|
|
||||||
float sideLen = length(side);
|
|
||||||
side = sideLen > 1e-4 ? side / sideLen : vec3(0.0, 1.0, 0.0);
|
|
||||||
vec3 up = cross(f, side);
|
|
||||||
// Draw slightly narrower than the nominal width so adjacent lines keep a
|
|
||||||
// visible seam instead of merging into a flat sheet.
|
|
||||||
float w = aWidth * 0.85;
|
|
||||||
float h = aHeight;
|
|
||||||
// Extend both ends by half a width so consecutive boxes overlap at corners.
|
|
||||||
float along = position.x * len + (position.x * 2.0 - 1.0) * w * 0.5;
|
|
||||||
vec3 p = start + f * along + side * (position.y * w) + up * ((position.z - 0.5) * h);
|
|
||||||
vec3 n = normal.x * f + normal.y * side + normal.z * up;
|
|
||||||
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
|
||||||
vViewPos = mv.xyz;
|
|
||||||
vNormal = normalMatrix * n;
|
|
||||||
vColor = segmentColor(t);
|
|
||||||
gl_Position = projectionMatrix * mv;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const BOX_FRAGMENT = /* glsl */ `
|
|
||||||
// View-space directions of the key and fill lights (set per frame).
|
|
||||||
uniform vec3 uKeyLightDir;
|
|
||||||
uniform vec3 uFillLightDir;
|
|
||||||
in vec3 vNormal;
|
|
||||||
in vec3 vColor;
|
|
||||||
in vec3 vViewPos;
|
|
||||||
layout(location = 0) out vec4 fragColor;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vec3 n = normalize(vNormal);
|
|
||||||
vec3 v = normalize(-vViewPos);
|
|
||||||
// Fixed directional key light gives neighbouring lines distinct shading
|
|
||||||
// on their top and side faces regardless of where the camera is.
|
|
||||||
float key = max(dot(n, uKeyLightDir), 0.0);
|
|
||||||
float fill = max(dot(n, uFillLightDir), 0.0);
|
|
||||||
// Weak headlight so faces pointing at the camera never go black.
|
|
||||||
float head = max(dot(n, v), 0.0);
|
|
||||||
vec3 h = normalize(uKeyLightDir + v);
|
|
||||||
float spec = pow(max(dot(n, h), 0.0), 32.0) * 0.18 * key;
|
|
||||||
// Keep the lit side below full brightness so saturated palette colours
|
|
||||||
// (cyan, yellow, orange) still show the top/side shading instead of
|
|
||||||
// clipping to a flat sheet.
|
|
||||||
vec3 base = vColor * 0.8;
|
|
||||||
float light = 0.28 + 0.62 * key + 0.18 * fill + 0.12 * head;
|
|
||||||
vec3 c = min(base * light + vec3(spec), vec3(1.0));
|
|
||||||
fragColor = linearToOutputTexel(vec4(c, 1.0));
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const LINE_VERTEX = /* glsl */ `
|
|
||||||
${COMMON_VERTEX}
|
|
||||||
out vec3 vColor;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
int t = int(aType + 0.5);
|
|
||||||
if (!segmentVisible(t)) {
|
|
||||||
gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
|
|
||||||
vColor = vec3(0.0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
vec3 p = mix(instanceStart, segmentEnd(), position.x);
|
|
||||||
vColor = segmentColor(t);
|
|
||||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const LINE_FRAGMENT = /* glsl */ `
|
|
||||||
uniform float uOpacity;
|
|
||||||
in vec3 vColor;
|
|
||||||
layout(location = 0) out vec4 fragColor;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
fragColor = linearToOutputTexel(vec4(vColor, uOpacity));
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export function createExtrusionMaterial(shared) {
|
|
||||||
return new THREE.ShaderMaterial({
|
|
||||||
glslVersion: THREE.GLSL3,
|
|
||||||
uniforms: {
|
|
||||||
...shared,
|
|
||||||
uTypeMask: { value: 0 },
|
|
||||||
uKeyLightDir: { value: new THREE.Vector3(0, 0, 1) },
|
|
||||||
uFillLightDir: { value: new THREE.Vector3(0, 0, 1) }
|
|
||||||
},
|
|
||||||
vertexShader: BOX_VERTEX,
|
|
||||||
fragmentShader: BOX_FRAGMENT,
|
|
||||||
side: THREE.FrontSide
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLineMaterial(shared) {
|
|
||||||
return new THREE.ShaderMaterial({
|
|
||||||
glslVersion: THREE.GLSL3,
|
|
||||||
uniforms: {
|
|
||||||
...shared,
|
|
||||||
uTypeMask: { value: 0 },
|
|
||||||
uOpacity: { value: 0.75 }
|
|
||||||
},
|
|
||||||
vertexShader: LINE_VERTEX,
|
|
||||||
fragmentShader: LINE_FRAGMENT,
|
|
||||||
transparent: true,
|
|
||||||
depthWrite: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
import PropTypes from 'prop-types'
|
|
||||||
import { Button, Flex, Typography } from 'antd'
|
|
||||||
import { FEATURE, FEATURE_INFO } from './featureTypes.js'
|
|
||||||
import { TOOL_PALETTE, rampCss } from './colorModes.js'
|
|
||||||
import InputNumberCal from '../../../Dashboard/common/InputNumberCal.jsx'
|
|
||||||
|
|
||||||
const { Text } = Typography
|
|
||||||
|
|
||||||
function Swatch({ color }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: color,
|
|
||||||
flex: '0 0 auto'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Swatch.propTypes = { color: PropTypes.string }
|
|
||||||
|
|
||||||
function LegendItem({ color, label, textColor, disabled, onClick }) {
|
|
||||||
const interactive = typeof onClick === 'function'
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
gap={6}
|
|
||||||
align='center'
|
|
||||||
role={interactive ? 'button' : undefined}
|
|
||||||
tabIndex={interactive ? 0 : undefined}
|
|
||||||
aria-pressed={interactive ? !disabled : undefined}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={
|
|
||||||
interactive
|
|
||||||
? (event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault()
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
cursor: interactive ? 'pointer' : 'default',
|
|
||||||
opacity: disabled ? 0.5 : 1,
|
|
||||||
userSelect: 'none',
|
|
||||||
transition: 'opacity 0.15s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Swatch color={color} />
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 1,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
textDecoration: disabled ? 'line-through' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LegendItem.propTypes = {
|
|
||||||
color: PropTypes.string,
|
|
||||||
label: PropTypes.string,
|
|
||||||
textColor: PropTypes.string,
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
onClick: PropTypes.func
|
|
||||||
}
|
|
||||||
|
|
||||||
function GCodeLegend({
|
|
||||||
mode,
|
|
||||||
model,
|
|
||||||
isDarkMode,
|
|
||||||
hiddenTypes,
|
|
||||||
onToggleType,
|
|
||||||
showTravel,
|
|
||||||
onToggleTravel,
|
|
||||||
range,
|
|
||||||
autoRange,
|
|
||||||
onRangeChange
|
|
||||||
}) {
|
|
||||||
if (!model || !mode) return null
|
|
||||||
const textColor = isDarkMode ? '#d9d9d9' : '#595959'
|
|
||||||
|
|
||||||
if (mode.kind === 'category') {
|
|
||||||
const counts = model.typeCounts || []
|
|
||||||
const rows = FEATURE_INFO.filter(
|
|
||||||
(info) =>
|
|
||||||
!info.hidden && info.id !== FEATURE.TRAVEL && (counts[info.id] || 0) > 0
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Flex gap='middle' align='center' wrap='wrap' justify='center'>
|
|
||||||
{rows.map((info) => (
|
|
||||||
<LegendItem
|
|
||||||
key={info.id}
|
|
||||||
color={info.color}
|
|
||||||
label={info.label}
|
|
||||||
textColor={textColor}
|
|
||||||
disabled={hiddenTypes.has(info.id)}
|
|
||||||
onClick={() => onToggleType(info.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<LegendItem
|
|
||||||
color={FEATURE_INFO[FEATURE.TRAVEL].color}
|
|
||||||
label='Travel'
|
|
||||||
textColor={textColor}
|
|
||||||
disabled={!showTravel}
|
|
||||||
onClick={onToggleTravel}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode.kind === 'tool') {
|
|
||||||
const tools = model.tools?.length ? model.tools : [0]
|
|
||||||
const fileColors = model.metadata?.filamentColors || []
|
|
||||||
return (
|
|
||||||
<Flex gap='middle' align='center' wrap='wrap' justify='center'>
|
|
||||||
{tools.map((tool) => {
|
|
||||||
const color =
|
|
||||||
(mode.useFilamentColors && fileColors[tool]) ||
|
|
||||||
TOOL_PALETTE[tool % TOOL_PALETTE.length]
|
|
||||||
return (
|
|
||||||
<LegendItem
|
|
||||||
key={tool}
|
|
||||||
color={color}
|
|
||||||
textColor={textColor}
|
|
||||||
label={
|
|
||||||
mode.useFilamentColors && fileColors[tool]
|
|
||||||
? `Filament ${tool + 1} (${fileColors[tool]})`
|
|
||||||
: `Tool ${tool}`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const format = mode.format || ((v) => String(v))
|
|
||||||
const min = range?.min ?? autoRange?.min ?? 0
|
|
||||||
const max = range?.max ?? autoRange?.max ?? 1
|
|
||||||
const overridden =
|
|
||||||
range != null &&
|
|
||||||
autoRange != null &&
|
|
||||||
(range.min !== autoRange.min || range.max !== autoRange.max)
|
|
||||||
const isDuration = mode.stat === 'layerTime'
|
|
||||||
const unitSuffix = mode.unit || undefined
|
|
||||||
return (
|
|
||||||
<Flex gap='small' align='center' wrap='wrap' justify='center'>
|
|
||||||
{isDuration ? (
|
|
||||||
<Text style={{ color: textColor, fontSize: 14 }}>{format(min)}</Text>
|
|
||||||
) : (
|
|
||||||
<InputNumberCal
|
|
||||||
value={Number(min.toFixed(2))}
|
|
||||||
onChange={(value) =>
|
|
||||||
Number.isFinite(value) && onRangeChange({ min: value, max })
|
|
||||||
}
|
|
||||||
suffix={unitSuffix}
|
|
||||||
style={{ width: 100 }}
|
|
||||||
controls={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 160,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: rampCss()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isDuration ? (
|
|
||||||
<Text style={{ color: textColor, fontSize: 14 }}>{format(max)}</Text>
|
|
||||||
) : (
|
|
||||||
<InputNumberCal
|
|
||||||
value={Number(max.toFixed(2))}
|
|
||||||
onChange={(value) =>
|
|
||||||
Number.isFinite(value) && onRangeChange({ min, max: value })
|
|
||||||
}
|
|
||||||
suffix={unitSuffix}
|
|
||||||
style={{ width: 100 }}
|
|
||||||
controls={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{overridden ? (
|
|
||||||
<Button size='small' type='link' onClick={() => onRangeChange(null)}>
|
|
||||||
Reset range
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
GCodeLegend.propTypes = {
|
|
||||||
mode: PropTypes.object,
|
|
||||||
model: PropTypes.object,
|
|
||||||
isDarkMode: PropTypes.bool,
|
|
||||||
hiddenTypes: PropTypes.instanceOf(Set).isRequired,
|
|
||||||
onToggleType: PropTypes.func.isRequired,
|
|
||||||
showTravel: PropTypes.bool,
|
|
||||||
onToggleTravel: PropTypes.func.isRequired,
|
|
||||||
range: PropTypes.shape({ min: PropTypes.number, max: PropTypes.number }),
|
|
||||||
autoRange: PropTypes.shape({ min: PropTypes.number, max: PropTypes.number }),
|
|
||||||
onRangeChange: PropTypes.func.isRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GCodeLegend
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
import * as THREE from 'three'
|
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
|
||||||
import Bed, { expandVolumeToBounds, normalizeBuildVolume } from './Bed.js'
|
|
||||||
import HotEnd from './HotEnd.js'
|
|
||||||
import SegmentMesh from './SegmentMesh.js'
|
|
||||||
|
|
||||||
// Owns the renderer, camera, controls and the scene graph. Rendering is on
|
|
||||||
// demand: anything that changes what is on screen calls requestRender().
|
|
||||||
export default class GCodeScene {
|
|
||||||
constructor(canvas) {
|
|
||||||
this.canvas = canvas
|
|
||||||
this.disposed = false
|
|
||||||
this.renderRequested = false
|
|
||||||
this.frame = this.frame.bind(this)
|
|
||||||
|
|
||||||
this.renderer = new THREE.WebGLRenderer({
|
|
||||||
canvas,
|
|
||||||
antialias: true,
|
|
||||||
alpha: false,
|
|
||||||
powerPreference: 'high-performance'
|
|
||||||
})
|
|
||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
|
||||||
|
|
||||||
this.scene = new THREE.Scene()
|
|
||||||
this.camera = new THREE.PerspectiveCamera(40, 1, 1, 50000)
|
|
||||||
this.camera.up.set(0, 0, 1)
|
|
||||||
this.scene.add(this.camera)
|
|
||||||
|
|
||||||
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x606060, 1.6)
|
|
||||||
this.scene.add(hemisphere)
|
|
||||||
// Fixed world-space key light (matches the extrusion shader's key light)
|
|
||||||
// plus a softer camera-attached fill.
|
|
||||||
const key = new THREE.DirectionalLight(0xffffff, 2.8)
|
|
||||||
key.position.set(-0.45, -0.35, 1)
|
|
||||||
this.scene.add(key)
|
|
||||||
const fill = new THREE.DirectionalLight(0xffffff, 1.1)
|
|
||||||
fill.position.set(0.3, 0.2, 1)
|
|
||||||
this.camera.add(fill)
|
|
||||||
this.lights = [hemisphere, key, fill]
|
|
||||||
|
|
||||||
this.controls = new OrbitControls(this.camera, canvas)
|
|
||||||
this.controls.enableDamping = false
|
|
||||||
this.controls.screenSpacePanning = true
|
|
||||||
this.controls.addEventListener('change', () => this.requestRender())
|
|
||||||
|
|
||||||
this.bed = new Bed()
|
|
||||||
this.segments = new SegmentMesh()
|
|
||||||
this.hotEnd = new HotEnd()
|
|
||||||
this.scene.add(this.bed.group, this.segments.group, this.hotEnd.group)
|
|
||||||
|
|
||||||
this.buildVolume = normalizeBuildVolume(null)
|
|
||||||
this.resize()
|
|
||||||
this.fitToVolume(this.buildVolume)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- camera -----------------------------------------------------------
|
|
||||||
|
|
||||||
fitToVolume(volume) {
|
|
||||||
const { minX, maxX, minY, maxY, maxZ } = volume
|
|
||||||
const sizeX = maxX - minX
|
|
||||||
const sizeY = maxY - minY
|
|
||||||
// Frame the bed footprint; a tall build volume should not push the
|
|
||||||
// camera away from where the first layers are printed.
|
|
||||||
const center = new THREE.Vector3(
|
|
||||||
(minX + maxX) / 2,
|
|
||||||
(minY + maxY) / 2,
|
|
||||||
Math.min(maxZ * 0.2, Math.max(sizeX, sizeY) * 0.15)
|
|
||||||
)
|
|
||||||
const radius = 0.5 * Math.sqrt(sizeX * sizeX + sizeY * sizeY)
|
|
||||||
const fov = THREE.MathUtils.degToRad(this.camera.fov)
|
|
||||||
const aspect = this.camera.aspect || 1
|
|
||||||
const horizontalFov = 2 * Math.atan(Math.tan(fov / 2) * aspect)
|
|
||||||
const limitingFov = Math.min(fov, horizontalFov)
|
|
||||||
const distance = (radius / Math.sin(limitingFov / 2)) * 0.8
|
|
||||||
const direction = new THREE.Vector3(0, -1, 0.75).normalize()
|
|
||||||
this.camera.position.copy(center).addScaledVector(direction, distance)
|
|
||||||
this.camera.near = Math.max(0.5, distance / 1000)
|
|
||||||
this.camera.updateProjectionMatrix()
|
|
||||||
this.controls.target.copy(center)
|
|
||||||
this.controls.update()
|
|
||||||
this.controls.saveState()
|
|
||||||
this.requestRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
setBuildVolume(volume, bounds) {
|
|
||||||
const next = expandVolumeToBounds(normalizeBuildVolume(volume), bounds)
|
|
||||||
const changed =
|
|
||||||
next.minX !== this.buildVolume.minX ||
|
|
||||||
next.maxX !== this.buildVolume.maxX ||
|
|
||||||
next.minY !== this.buildVolume.minY ||
|
|
||||||
next.maxY !== this.buildVolume.maxY ||
|
|
||||||
next.maxZ !== this.buildVolume.maxZ
|
|
||||||
if (!changed) return false
|
|
||||||
this.buildVolume = next
|
|
||||||
this.bed.setVolume(next)
|
|
||||||
this.requestRender()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
resize() {
|
|
||||||
const canvas = this.canvas
|
|
||||||
const width = canvas.clientWidth || 1
|
|
||||||
const height = canvas.clientHeight || 1
|
|
||||||
this.renderer.setSize(width, height, false)
|
|
||||||
this.camera.aspect = width / height
|
|
||||||
this.camera.updateProjectionMatrix()
|
|
||||||
this.requestRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- rendering --------------------------------------------------------
|
|
||||||
|
|
||||||
requestRender() {
|
|
||||||
if (this.renderRequested || this.disposed) return
|
|
||||||
this.renderRequested = true
|
|
||||||
requestAnimationFrame(this.frame)
|
|
||||||
}
|
|
||||||
|
|
||||||
frame() {
|
|
||||||
this.renderRequested = false
|
|
||||||
if (this.disposed) return
|
|
||||||
const animating = this.hotEnd.update()
|
|
||||||
this.camera.updateMatrixWorld()
|
|
||||||
this.segments.updateLights(this.camera)
|
|
||||||
this.renderer.render(this.scene, this.camera)
|
|
||||||
if (animating) this.requestRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- model ------------------------------------------------------------
|
|
||||||
|
|
||||||
setStore(store) {
|
|
||||||
this.segments.setStore(store)
|
|
||||||
this.requestRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
syncStore() {
|
|
||||||
this.segments.sync()
|
|
||||||
this.requestRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
setToolPosition(x, y, z, immediate = false) {
|
|
||||||
if (![x, y, z].every(Number.isFinite)) return
|
|
||||||
this.hotEnd.setTarget(x, y, z, immediate)
|
|
||||||
this.requestRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
setHotEndVisible(visible) {
|
|
||||||
if (this.hotEnd.group.visible === visible) return
|
|
||||||
this.hotEnd.setVisible(visible)
|
|
||||||
this.requestRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme({ background, grid }) {
|
|
||||||
if (background) this.renderer.setClearColor(new THREE.Color(background), 1)
|
|
||||||
if (grid) this.bed.setColor(grid)
|
|
||||||
this.requestRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.disposed = true
|
|
||||||
this.controls.dispose()
|
|
||||||
this.segments.dispose()
|
|
||||||
this.bed.dispose()
|
|
||||||
this.hotEnd.dispose()
|
|
||||||
for (const light of this.lights) light.dispose?.()
|
|
||||||
// No forceContextLoss: React StrictMode re-mounts onto the same canvas
|
|
||||||
// and a lost context cannot be re-created on it.
|
|
||||||
this.renderer.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import * as THREE from 'three'
|
|
||||||
|
|
||||||
// Stylised hot end (nozzle, heater block, heat break, finned heatsink) with
|
|
||||||
// the nozzle tip at the group origin pointing down -Z. Dimensions in mm.
|
|
||||||
export default class HotEnd {
|
|
||||||
constructor() {
|
|
||||||
this.group = new THREE.Group()
|
|
||||||
this.group.visible = false
|
|
||||||
this.target = new THREE.Vector3()
|
|
||||||
this.hasTarget = false
|
|
||||||
this.materials = []
|
|
||||||
this.geometries = []
|
|
||||||
this.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
material(color, options = {}) {
|
|
||||||
const material = new THREE.MeshStandardMaterial({
|
|
||||||
color,
|
|
||||||
metalness: options.metalness ?? 0.6,
|
|
||||||
roughness: options.roughness ?? 0.4
|
|
||||||
})
|
|
||||||
this.materials.push(material)
|
|
||||||
return material
|
|
||||||
}
|
|
||||||
|
|
||||||
cylinder(radiusTop, radiusBottom, height, z, material, segments = 24) {
|
|
||||||
const geometry = new THREE.CylinderGeometry(
|
|
||||||
radiusTop,
|
|
||||||
radiusBottom,
|
|
||||||
height,
|
|
||||||
segments
|
|
||||||
)
|
|
||||||
geometry.rotateX(Math.PI / 2)
|
|
||||||
geometry.translate(0, 0, z + height / 2)
|
|
||||||
this.geometries.push(geometry)
|
|
||||||
const mesh = new THREE.Mesh(geometry, material)
|
|
||||||
this.group.add(mesh)
|
|
||||||
return mesh
|
|
||||||
}
|
|
||||||
|
|
||||||
box(width, depth, height, x, y, z, material) {
|
|
||||||
const geometry = new THREE.BoxGeometry(width, depth, height)
|
|
||||||
geometry.translate(x, y, z + height / 2)
|
|
||||||
this.geometries.push(geometry)
|
|
||||||
const mesh = new THREE.Mesh(geometry, material)
|
|
||||||
this.group.add(mesh)
|
|
||||||
return mesh
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
const brass = this.material(0xc9a227, { metalness: 0.8, roughness: 0.35 })
|
|
||||||
const steel = this.material(0x8d9299, { metalness: 0.7, roughness: 0.5 })
|
|
||||||
const block = this.material(0x5c6066, { metalness: 0.5, roughness: 0.6 })
|
|
||||||
const fins = this.material(0xb9bec6, { metalness: 0.7, roughness: 0.45 })
|
|
||||||
const sock = this.material(0x2f6fdb, { metalness: 0.1, roughness: 0.9 })
|
|
||||||
|
|
||||||
// Nozzle tip and hex body.
|
|
||||||
this.cylinder(1.6, 0.35, 2.5, 0, brass)
|
|
||||||
this.cylinder(3.2, 3.2, 3, 2.5, brass, 6)
|
|
||||||
// Heater block with a silicone sock.
|
|
||||||
this.box(16, 10, 11, 3, 0, 5.5, block)
|
|
||||||
this.box(17, 11, 9, 3, 0, 6.5, sock)
|
|
||||||
// Heat break.
|
|
||||||
this.cylinder(1.6, 1.6, 6, 16.5, steel)
|
|
||||||
// Heatsink core and fins.
|
|
||||||
this.cylinder(4, 4, 24, 22.5, fins)
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
this.cylinder(10.5, 10.5, 1, 23 + i * 3, fins, 32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTarget(x, y, z, immediate = false) {
|
|
||||||
this.target.set(x, y, z)
|
|
||||||
this.hasTarget = true
|
|
||||||
if (immediate || !this.group.visible) {
|
|
||||||
this.group.position.copy(this.target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setVisible(visible) {
|
|
||||||
if (visible && !this.group.visible && this.hasTarget) {
|
|
||||||
this.group.position.copy(this.target)
|
|
||||||
}
|
|
||||||
this.group.visible = visible
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eases towards the target; returns true while still moving.
|
|
||||||
update() {
|
|
||||||
if (!this.group.visible || !this.hasTarget) return false
|
|
||||||
const position = this.group.position
|
|
||||||
const distance = position.distanceTo(this.target)
|
|
||||||
if (distance < 0.01) {
|
|
||||||
position.copy(this.target)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
position.lerp(this.target, distance > 50 ? 1 : 0.35)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
for (const geometry of this.geometries) geometry.dispose()
|
|
||||||
for (const material of this.materials) material.dispose()
|
|
||||||
this.geometries = []
|
|
||||||
this.materials = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,311 +0,0 @@
|
|||||||
import * as THREE from 'three'
|
|
||||||
import {
|
|
||||||
createExtrusionMaterial,
|
|
||||||
createLineMaterial,
|
|
||||||
createSharedUniforms,
|
|
||||||
MAX_TOOLS
|
|
||||||
} from './ExtrusionMaterial.js'
|
|
||||||
import {
|
|
||||||
ALL_TYPES_MASK,
|
|
||||||
EXTRUSION_TYPES_MASK,
|
|
||||||
FEATURE_INFO,
|
|
||||||
LINE_TYPES_MASK,
|
|
||||||
MAX_FEATURE_TYPES
|
|
||||||
} from './featureTypes.js'
|
|
||||||
import { TOOL_PALETTE } from './colorModes.js'
|
|
||||||
|
|
||||||
// GPU representation of a SegmentStore. Every segment is one instance; the
|
|
||||||
// box geometry renders extrusions and the two-vertex line geometry renders
|
|
||||||
// travels (and everything in "lines" mode). Both geometries share the same
|
|
||||||
// attribute objects, so each field lives in GPU memory exactly once.
|
|
||||||
|
|
||||||
function createBoxBase() {
|
|
||||||
const box = new THREE.BoxGeometry(1, 1, 1)
|
|
||||||
box.translate(0.5, 0, 0)
|
|
||||||
return box
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLineBase() {
|
|
||||||
const geometry = new THREE.BufferGeometry()
|
|
||||||
geometry.setAttribute(
|
|
||||||
'position',
|
|
||||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0], 3)
|
|
||||||
)
|
|
||||||
return geometry
|
|
||||||
}
|
|
||||||
|
|
||||||
const INSTANCE_FIELDS = [
|
|
||||||
['aType', 'type'],
|
|
||||||
['aTool', 'tool'],
|
|
||||||
['aFan', 'fan'],
|
|
||||||
['aLayer', 'layer'],
|
|
||||||
['aTemp', 'temp'],
|
|
||||||
['aWidth', 'width'],
|
|
||||||
['aHeight', 'height'],
|
|
||||||
['aFeed', 'feed'],
|
|
||||||
['aAccel', 'accel'],
|
|
||||||
['aActualSpeed', 'actualSpeed'],
|
|
||||||
['aLayerTime', 'layerTime']
|
|
||||||
]
|
|
||||||
|
|
||||||
export default class SegmentMesh {
|
|
||||||
constructor() {
|
|
||||||
this.group = new THREE.Group()
|
|
||||||
this.shared = createSharedUniforms()
|
|
||||||
this.boxMaterial = createExtrusionMaterial(this.shared)
|
|
||||||
this.lineMaterial = createLineMaterial(this.shared)
|
|
||||||
this.boxBase = createBoxBase()
|
|
||||||
this.lineBase = createLineBase()
|
|
||||||
// World-space light directions; converted to view space per frame.
|
|
||||||
this.keyLightWorld = new THREE.Vector3(-0.45, -0.35, 1).normalize()
|
|
||||||
this.fillLightWorld = new THREE.Vector3(0.7, 0.6, 0.25).normalize()
|
|
||||||
|
|
||||||
this.boxGeometry = null
|
|
||||||
this.lineGeometry = null
|
|
||||||
this.mesh = null
|
|
||||||
this.lines = null
|
|
||||||
this.positionBuffer = null
|
|
||||||
this.attributes = {}
|
|
||||||
|
|
||||||
this.store = null
|
|
||||||
this.generation = -1
|
|
||||||
this.uploaded = 0
|
|
||||||
this.plannerUploaded = false
|
|
||||||
|
|
||||||
this.renderMode = 'extrusion'
|
|
||||||
this.visibleMask = ALL_TYPES_MASK & ~(1 << 1) // travel hidden by default
|
|
||||||
this.applyMasks()
|
|
||||||
this.setTypeColors()
|
|
||||||
this.setToolColors()
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore(store) {
|
|
||||||
this.store = store
|
|
||||||
this.generation = -1
|
|
||||||
this.uploaded = 0
|
|
||||||
this.plannerUploaded = false
|
|
||||||
this.shared.uVisibleEnd.value = 0
|
|
||||||
this.sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload anything new since the last sync. Cheap when nothing changed.
|
|
||||||
sync() {
|
|
||||||
const store = this.store
|
|
||||||
if (!store) {
|
|
||||||
this.disposeGeometry()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (store.generation !== this.generation) {
|
|
||||||
this.rebuild()
|
|
||||||
} else if (store.count > this.uploaded) {
|
|
||||||
this.uploadRange(this.uploaded, store.count)
|
|
||||||
}
|
|
||||||
if (store.plannerReady && !this.plannerUploaded) {
|
|
||||||
this.uploadPlanner()
|
|
||||||
}
|
|
||||||
this.uploaded = store.count
|
|
||||||
if (this.boxGeometry) this.boxGeometry.instanceCount = store.count
|
|
||||||
if (this.lineGeometry) this.lineGeometry.instanceCount = store.count
|
|
||||||
}
|
|
||||||
|
|
||||||
rebuild() {
|
|
||||||
this.disposeGeometry()
|
|
||||||
const store = this.store
|
|
||||||
this.generation = store.generation
|
|
||||||
|
|
||||||
const positionBuffer = new THREE.InstancedInterleavedBuffer(
|
|
||||||
store.positions,
|
|
||||||
3,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
positionBuffer.setUsage(THREE.DynamicDrawUsage)
|
|
||||||
this.positionBuffer = positionBuffer
|
|
||||||
const instanceStart = new THREE.InterleavedBufferAttribute(
|
|
||||||
positionBuffer,
|
|
||||||
3,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
const instanceEnd = new THREE.InterleavedBufferAttribute(
|
|
||||||
positionBuffer,
|
|
||||||
3,
|
|
||||||
3
|
|
||||||
)
|
|
||||||
|
|
||||||
this.attributes = {}
|
|
||||||
for (const [name, field] of INSTANCE_FIELDS) {
|
|
||||||
const attribute = new THREE.InstancedBufferAttribute(store[field], 1)
|
|
||||||
attribute.setUsage(THREE.DynamicDrawUsage)
|
|
||||||
this.attributes[name] = attribute
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyInstanceAttributes = (geometry) => {
|
|
||||||
geometry.setAttribute('instanceStart', instanceStart)
|
|
||||||
geometry.setAttribute('instanceEnd', instanceEnd)
|
|
||||||
for (const name of Object.keys(this.attributes)) {
|
|
||||||
geometry.setAttribute(name, this.attributes[name])
|
|
||||||
}
|
|
||||||
geometry.instanceCount = store.count
|
|
||||||
// The bounding volume would otherwise be computed from the base
|
|
||||||
// geometry only; culling is skipped since the model fills the view.
|
|
||||||
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6)
|
|
||||||
}
|
|
||||||
|
|
||||||
const boxGeometry = new THREE.InstancedBufferGeometry()
|
|
||||||
boxGeometry.index = this.boxBase.index
|
|
||||||
boxGeometry.setAttribute('position', this.boxBase.attributes.position)
|
|
||||||
boxGeometry.setAttribute('normal', this.boxBase.attributes.normal)
|
|
||||||
applyInstanceAttributes(boxGeometry)
|
|
||||||
|
|
||||||
const lineGeometry = new THREE.InstancedBufferGeometry()
|
|
||||||
lineGeometry.setAttribute('position', this.lineBase.attributes.position)
|
|
||||||
applyInstanceAttributes(lineGeometry)
|
|
||||||
|
|
||||||
this.boxGeometry = boxGeometry
|
|
||||||
this.lineGeometry = lineGeometry
|
|
||||||
|
|
||||||
this.mesh = new THREE.Mesh(boxGeometry, this.boxMaterial)
|
|
||||||
this.mesh.frustumCulled = false
|
|
||||||
this.mesh.visible = this.renderMode === 'extrusion'
|
|
||||||
this.lines = new THREE.LineSegments(lineGeometry, this.lineMaterial)
|
|
||||||
this.lines.frustumCulled = false
|
|
||||||
this.group.add(this.mesh, this.lines)
|
|
||||||
|
|
||||||
this.uploaded = store.count
|
|
||||||
this.plannerUploaded = false
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadRange(from, to) {
|
|
||||||
if (to <= from) return
|
|
||||||
const count = to - from
|
|
||||||
// Segment `to - 1` ends at vertex `to`, so include one extra vertex.
|
|
||||||
this.positionBuffer.addUpdateRange(from * 3, (count + 1) * 3)
|
|
||||||
this.positionBuffer.needsUpdate = true
|
|
||||||
for (const [name] of INSTANCE_FIELDS) {
|
|
||||||
if (name === 'aActualSpeed' || name === 'aLayerTime') continue
|
|
||||||
const attribute = this.attributes[name]
|
|
||||||
attribute.addUpdateRange(from, count)
|
|
||||||
attribute.needsUpdate = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadPlanner() {
|
|
||||||
for (const name of ['aActualSpeed', 'aLayerTime']) {
|
|
||||||
const attribute = this.attributes[name]
|
|
||||||
if (!attribute) continue
|
|
||||||
attribute.addUpdateRange(0, this.store.count)
|
|
||||||
attribute.needsUpdate = true
|
|
||||||
}
|
|
||||||
this.plannerUploaded = true
|
|
||||||
}
|
|
||||||
|
|
||||||
disposeGeometry() {
|
|
||||||
if (this.mesh) this.group.remove(this.mesh)
|
|
||||||
if (this.lines) this.group.remove(this.lines)
|
|
||||||
this.boxGeometry?.dispose()
|
|
||||||
this.lineGeometry?.dispose()
|
|
||||||
this.boxGeometry = null
|
|
||||||
this.lineGeometry = null
|
|
||||||
this.mesh = null
|
|
||||||
this.lines = null
|
|
||||||
this.positionBuffer = null
|
|
||||||
this.attributes = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.disposeGeometry()
|
|
||||||
this.boxBase.dispose()
|
|
||||||
this.lineBase.dispose()
|
|
||||||
this.boxMaterial.dispose()
|
|
||||||
this.lineMaterial.dispose()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- uniform driven state -------------------------------------------
|
|
||||||
|
|
||||||
// Call before rendering so the fixed world lights follow camera rotation.
|
|
||||||
updateLights(camera) {
|
|
||||||
const { uKeyLightDir, uFillLightDir } = this.boxMaterial.uniforms
|
|
||||||
uKeyLightDir.value
|
|
||||||
.copy(this.keyLightWorld)
|
|
||||||
.transformDirection(camera.matrixWorldInverse)
|
|
||||||
uFillLightDir.value
|
|
||||||
.copy(this.fillLightWorld)
|
|
||||||
.transformDirection(camera.matrixWorldInverse)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyMasks() {
|
|
||||||
const mask = this.visibleMask & ALL_TYPES_MASK
|
|
||||||
if (this.renderMode === 'lines') {
|
|
||||||
this.boxMaterial.uniforms.uTypeMask.value = 0
|
|
||||||
this.lineMaterial.uniforms.uTypeMask.value = mask
|
|
||||||
this.lineMaterial.uniforms.uOpacity.value = 1
|
|
||||||
this.lineMaterial.transparent = false
|
|
||||||
this.lineMaterial.depthWrite = true
|
|
||||||
} else {
|
|
||||||
this.boxMaterial.uniforms.uTypeMask.value = mask & EXTRUSION_TYPES_MASK
|
|
||||||
this.lineMaterial.uniforms.uTypeMask.value = mask & LINE_TYPES_MASK
|
|
||||||
this.lineMaterial.uniforms.uOpacity.value = 0.7
|
|
||||||
this.lineMaterial.transparent = true
|
|
||||||
this.lineMaterial.depthWrite = false
|
|
||||||
}
|
|
||||||
if (this.mesh) this.mesh.visible = this.renderMode === 'extrusion'
|
|
||||||
}
|
|
||||||
|
|
||||||
setRenderMode(mode) {
|
|
||||||
this.renderMode = mode === 'lines' ? 'lines' : 'extrusion'
|
|
||||||
this.applyMasks()
|
|
||||||
}
|
|
||||||
|
|
||||||
setVisibleMask(mask) {
|
|
||||||
this.visibleMask = mask
|
|
||||||
this.applyMasks()
|
|
||||||
}
|
|
||||||
|
|
||||||
setColorMode(shaderMode, logScale = false) {
|
|
||||||
this.shared.uColorMode.value = shaderMode | 0
|
|
||||||
this.shared.uLogScale.value = logScale ? 1 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
setRange(min, max) {
|
|
||||||
this.shared.uRangeMin.value = Number.isFinite(min) ? min : 0
|
|
||||||
this.shared.uRangeMax.value = Number.isFinite(max) ? max : 1
|
|
||||||
}
|
|
||||||
|
|
||||||
setLayerRange(min, max) {
|
|
||||||
this.shared.uLayerMin.value = min
|
|
||||||
this.shared.uLayerMax.value = max
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show segments [0, end) fully and fraction `t` of segment `end - 1`.
|
|
||||||
setProgress(end, t = 1) {
|
|
||||||
const count = this.store ? this.store.count : 0
|
|
||||||
const clamped = Math.max(0, Math.min(count, end | 0))
|
|
||||||
this.shared.uVisibleEnd.value = clamped
|
|
||||||
this.shared.uProgressT.value = Math.max(0, Math.min(1, t))
|
|
||||||
}
|
|
||||||
|
|
||||||
showAll() {
|
|
||||||
this.shared.uVisibleEnd.value = this.store ? this.store.count : 0
|
|
||||||
this.shared.uProgressT.value = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
setTypeColors(overrides = {}) {
|
|
||||||
const colors = this.shared.uTypeColors.value
|
|
||||||
for (let i = 0; i < MAX_FEATURE_TYPES; i++) {
|
|
||||||
const info = FEATURE_INFO[i]
|
|
||||||
const hex = overrides[i] || info?.color || '#888888'
|
|
||||||
colors[i].set(hex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setToolColors(hexColors = []) {
|
|
||||||
const colors = this.shared.uToolColors.value
|
|
||||||
for (let i = 0; i < MAX_TOOLS; i++) {
|
|
||||||
const hex = hexColors[i] || TOOL_PALETTE[i % TOOL_PALETTE.length]
|
|
||||||
try {
|
|
||||||
colors[i].set(hex)
|
|
||||||
} catch {
|
|
||||||
colors[i].set(TOOL_PALETTE[i % TOOL_PALETTE.length])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
// Colour modes mirror Orca Slicer's preview "view type" list, plus
|
|
||||||
// acceleration. `shaderMode` is the integer the shaders switch on; `stat` is
|
|
||||||
// the key in the worker's range statistics used for auto ranging.
|
|
||||||
|
|
||||||
export const SHADER_MODE = Object.freeze({
|
|
||||||
FEATURE: 0,
|
|
||||||
HEIGHT: 1,
|
|
||||||
WIDTH: 2,
|
|
||||||
SPEED: 3,
|
|
||||||
ACTUAL_SPEED: 4,
|
|
||||||
ACCEL: 5,
|
|
||||||
FAN: 6,
|
|
||||||
TEMP: 7,
|
|
||||||
FLOW: 8,
|
|
||||||
ACTUAL_FLOW: 9,
|
|
||||||
LAYER_TIME: 10,
|
|
||||||
TOOL: 11
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatNumber = (digits) => (value) =>
|
|
||||||
Number.isFinite(value) ? value.toFixed(digits) : '-'
|
|
||||||
|
|
||||||
export function formatDuration(seconds) {
|
|
||||||
if (!Number.isFinite(seconds)) return '-'
|
|
||||||
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`
|
|
||||||
const minutes = Math.floor(seconds / 60)
|
|
||||||
const rest = Math.round(seconds - minutes * 60)
|
|
||||||
if (minutes < 60) return `${minutes}m ${rest}s`
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
return `${hours}h ${minutes - hours * 60}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COLOR_MODES = [
|
|
||||||
{
|
|
||||||
value: 'feature',
|
|
||||||
label: 'Line type',
|
|
||||||
kind: 'category',
|
|
||||||
shaderMode: SHADER_MODE.FEATURE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'height',
|
|
||||||
label: 'Height',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.HEIGHT,
|
|
||||||
stat: 'height',
|
|
||||||
unit: 'mm',
|
|
||||||
format: formatNumber(2)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'width',
|
|
||||||
label: 'Width',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.WIDTH,
|
|
||||||
stat: 'width',
|
|
||||||
unit: 'mm',
|
|
||||||
format: formatNumber(2)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'speed',
|
|
||||||
label: 'Speed',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.SPEED,
|
|
||||||
stat: 'feed',
|
|
||||||
unit: 'mm/s',
|
|
||||||
format: formatNumber(0)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'actualSpeed',
|
|
||||||
label: 'Actual speed',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.ACTUAL_SPEED,
|
|
||||||
stat: 'actualSpeed',
|
|
||||||
unit: 'mm/s',
|
|
||||||
format: formatNumber(0),
|
|
||||||
requiresPlanner: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'accel',
|
|
||||||
label: 'Acceleration',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.ACCEL,
|
|
||||||
stat: 'accel',
|
|
||||||
unit: 'mm/s²',
|
|
||||||
format: formatNumber(0)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'fan',
|
|
||||||
label: 'Fan speed',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.FAN,
|
|
||||||
stat: 'fan',
|
|
||||||
unit: '%',
|
|
||||||
format: formatNumber(0)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'temp',
|
|
||||||
label: 'Temperature',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.TEMP,
|
|
||||||
stat: 'temp',
|
|
||||||
unit: '°C',
|
|
||||||
format: formatNumber(0)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'flow',
|
|
||||||
label: 'Volumetric flow',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.FLOW,
|
|
||||||
stat: 'flow',
|
|
||||||
unit: 'mm³/s',
|
|
||||||
format: formatNumber(1)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'actualFlow',
|
|
||||||
label: 'Actual volumetric flow',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.ACTUAL_FLOW,
|
|
||||||
stat: 'actualFlow',
|
|
||||||
unit: 'mm³/s',
|
|
||||||
format: formatNumber(1),
|
|
||||||
requiresPlanner: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'layerTime',
|
|
||||||
label: 'Layer time',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.LAYER_TIME,
|
|
||||||
stat: 'layerTime',
|
|
||||||
unit: '',
|
|
||||||
format: formatDuration,
|
|
||||||
requiresPlanner: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'layerTimeLog',
|
|
||||||
label: 'Layer time (log)',
|
|
||||||
kind: 'scalar',
|
|
||||||
shaderMode: SHADER_MODE.LAYER_TIME,
|
|
||||||
stat: 'layerTime',
|
|
||||||
unit: '',
|
|
||||||
format: formatDuration,
|
|
||||||
logScale: true,
|
|
||||||
requiresPlanner: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'tool',
|
|
||||||
label: 'Tool',
|
|
||||||
kind: 'tool',
|
|
||||||
shaderMode: SHADER_MODE.TOOL
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'filament',
|
|
||||||
label: 'Filament',
|
|
||||||
kind: 'tool',
|
|
||||||
shaderMode: SHADER_MODE.TOOL,
|
|
||||||
useFilamentColors: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export const COLOR_MODE_BY_VALUE = new Map(
|
|
||||||
COLOR_MODES.map((mode) => [mode.value, mode])
|
|
||||||
)
|
|
||||||
|
|
||||||
// Orca Slicer's scalar ramp (blue -> green -> yellow -> red), 11 stops.
|
|
||||||
export const RAMP_STOPS = [
|
|
||||||
'#0b2c7a',
|
|
||||||
'#135985',
|
|
||||||
'#1c8891',
|
|
||||||
'#04d60f',
|
|
||||||
'#aaf200',
|
|
||||||
'#fcf803',
|
|
||||||
'#f5ce0a',
|
|
||||||
'#e38820',
|
|
||||||
'#d16830',
|
|
||||||
'#c2523c',
|
|
||||||
'#942616'
|
|
||||||
]
|
|
||||||
|
|
||||||
// Distinct colours for tools when the file has no filament colours.
|
|
||||||
export const TOOL_PALETTE = [
|
|
||||||
'#00a2ff',
|
|
||||||
'#ff8b2d',
|
|
||||||
'#2dd36f',
|
|
||||||
'#ff4d6d',
|
|
||||||
'#b36bff',
|
|
||||||
'#ffd43b',
|
|
||||||
'#20c9b8',
|
|
||||||
'#ff6b00',
|
|
||||||
'#7a9cff',
|
|
||||||
'#d6336c',
|
|
||||||
'#69db7c',
|
|
||||||
'#f783ac',
|
|
||||||
'#4dabf7',
|
|
||||||
'#ffa94d',
|
|
||||||
'#9775fa',
|
|
||||||
'#a9e34b'
|
|
||||||
]
|
|
||||||
|
|
||||||
export function rampCss() {
|
|
||||||
return `linear-gradient(to right, ${RAMP_STOPS.join(', ')})`
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
// Canonical feature (line) types shared by the worker, the shaders and the UI.
|
|
||||||
// Index 0 is reserved for segments that must never render (position jumps),
|
|
||||||
// index 1 is travel. Everything above is an extrusion type.
|
|
||||||
|
|
||||||
export const FEATURE = Object.freeze({
|
|
||||||
NONE: 0,
|
|
||||||
TRAVEL: 1,
|
|
||||||
OUTER_WALL: 2,
|
|
||||||
INNER_WALL: 3,
|
|
||||||
OVERHANG_WALL: 4,
|
|
||||||
SPARSE_INFILL: 5,
|
|
||||||
SOLID_INFILL: 6,
|
|
||||||
TOP_SURFACE: 7,
|
|
||||||
BOTTOM_SURFACE: 8,
|
|
||||||
IRONING: 9,
|
|
||||||
BRIDGE: 10,
|
|
||||||
GAP_FILL: 11,
|
|
||||||
SKIRT: 12,
|
|
||||||
BRIM: 13,
|
|
||||||
SUPPORT: 14,
|
|
||||||
SUPPORT_INTERFACE: 15,
|
|
||||||
PRIME_TOWER: 16,
|
|
||||||
WIPE: 17,
|
|
||||||
CUSTOM: 18,
|
|
||||||
UNKNOWN: 19
|
|
||||||
})
|
|
||||||
|
|
||||||
export const FEATURE_COUNT = 20
|
|
||||||
export const MAX_FEATURE_TYPES = 32
|
|
||||||
|
|
||||||
// Orca Slicer palette.
|
|
||||||
export const FEATURE_INFO = [
|
|
||||||
{ id: FEATURE.NONE, label: 'Hidden', color: '#000000', hidden: true },
|
|
||||||
{ id: FEATURE.TRAVEL, label: 'Travel', color: '#8c8c8c', travel: true },
|
|
||||||
{ id: FEATURE.OUTER_WALL, label: 'Outer wall', color: '#ff7d38' },
|
|
||||||
{ id: FEATURE.INNER_WALL, label: 'Inner wall', color: '#ffe64d' },
|
|
||||||
{ id: FEATURE.OVERHANG_WALL, label: 'Overhang wall', color: '#1f4cff' },
|
|
||||||
{ id: FEATURE.SPARSE_INFILL, label: 'Sparse infill', color: '#b03c7f' },
|
|
||||||
{
|
|
||||||
id: FEATURE.SOLID_INFILL,
|
|
||||||
label: 'Internal solid infill',
|
|
||||||
color: '#9c0ed0'
|
|
||||||
},
|
|
||||||
{ id: FEATURE.TOP_SURFACE, label: 'Top surface', color: '#ff3d3d' },
|
|
||||||
{ id: FEATURE.BOTTOM_SURFACE, label: 'Bottom surface', color: '#ff6669' },
|
|
||||||
{ id: FEATURE.IRONING, label: 'Ironing', color: '#ffc8ff' },
|
|
||||||
{ id: FEATURE.BRIDGE, label: 'Bridge', color: '#4d80bd' },
|
|
||||||
{ id: FEATURE.GAP_FILL, label: 'Gap infill', color: '#ffffff' },
|
|
||||||
{ id: FEATURE.SKIRT, label: 'Skirt', color: '#00ffff' },
|
|
||||||
{ id: FEATURE.BRIM, label: 'Brim', color: '#6fe0ff' },
|
|
||||||
{ id: FEATURE.SUPPORT, label: 'Support', color: '#00ff00' },
|
|
||||||
{
|
|
||||||
id: FEATURE.SUPPORT_INTERFACE,
|
|
||||||
label: 'Support interface',
|
|
||||||
color: '#4dff80'
|
|
||||||
},
|
|
||||||
{ id: FEATURE.PRIME_TOWER, label: 'Prime tower', color: '#b3b3b3' },
|
|
||||||
{ id: FEATURE.WIPE, label: 'Wipe', color: '#ffff7d' },
|
|
||||||
{ id: FEATURE.CUSTOM, label: 'Custom', color: '#e6b35c' },
|
|
||||||
{ id: FEATURE.UNKNOWN, label: 'Other', color: '#b8b8b8' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Normalised slicer strings -> feature. Keys are lower-cased and have
|
|
||||||
// whitespace, dashes and underscores stripped so the same table handles
|
|
||||||
// Orca, Bambu, Prusa, SuperSlicer, Cura and ideaMaker variants.
|
|
||||||
const FEATURE_LOOKUP = new Map(
|
|
||||||
Object.entries({
|
|
||||||
// Orca / Bambu
|
|
||||||
outerwall: FEATURE.OUTER_WALL,
|
|
||||||
innerwall: FEATURE.INNER_WALL,
|
|
||||||
overhangwall: FEATURE.OVERHANG_WALL,
|
|
||||||
sparseinfill: FEATURE.SPARSE_INFILL,
|
|
||||||
internalsolidinfill: FEATURE.SOLID_INFILL,
|
|
||||||
topsurface: FEATURE.TOP_SURFACE,
|
|
||||||
bottomsurface: FEATURE.BOTTOM_SURFACE,
|
|
||||||
ironing: FEATURE.IRONING,
|
|
||||||
bridge: FEATURE.BRIDGE,
|
|
||||||
internalbridge: FEATURE.BRIDGE,
|
|
||||||
gapinfill: FEATURE.GAP_FILL,
|
|
||||||
skirt: FEATURE.SKIRT,
|
|
||||||
brim: FEATURE.BRIM,
|
|
||||||
support: FEATURE.SUPPORT,
|
|
||||||
supportinterface: FEATURE.SUPPORT_INTERFACE,
|
|
||||||
supporttransition: FEATURE.SUPPORT,
|
|
||||||
primetower: FEATURE.PRIME_TOWER,
|
|
||||||
custom: FEATURE.CUSTOM,
|
|
||||||
multiple: FEATURE.UNKNOWN,
|
|
||||||
wipe: FEATURE.WIPE,
|
|
||||||
// Prusa / SuperSlicer
|
|
||||||
perimeter: FEATURE.INNER_WALL,
|
|
||||||
externalperimeter: FEATURE.OUTER_WALL,
|
|
||||||
overhangperimeter: FEATURE.OVERHANG_WALL,
|
|
||||||
internalinfill: FEATURE.SPARSE_INFILL,
|
|
||||||
solidinfill: FEATURE.SOLID_INFILL,
|
|
||||||
topsolidinfill: FEATURE.TOP_SURFACE,
|
|
||||||
bottomsolidinfill: FEATURE.BOTTOM_SURFACE,
|
|
||||||
bridgeinfill: FEATURE.BRIDGE,
|
|
||||||
internalbridgeinfill: FEATURE.BRIDGE,
|
|
||||||
thinwall: FEATURE.GAP_FILL,
|
|
||||||
gapfill: FEATURE.GAP_FILL,
|
|
||||||
'skirt/brim': FEATURE.SKIRT,
|
|
||||||
skirtbrim: FEATURE.SKIRT,
|
|
||||||
supportmaterial: FEATURE.SUPPORT,
|
|
||||||
supportmaterialinterface: FEATURE.SUPPORT_INTERFACE,
|
|
||||||
wipetower: FEATURE.PRIME_TOWER,
|
|
||||||
// Cura
|
|
||||||
wallouter: FEATURE.OUTER_WALL,
|
|
||||||
wallinner: FEATURE.INNER_WALL,
|
|
||||||
skin: FEATURE.TOP_SURFACE,
|
|
||||||
fill: FEATURE.SPARSE_INFILL,
|
|
||||||
supportinterfacecura: FEATURE.SUPPORT_INTERFACE,
|
|
||||||
// ideaMaker / Simplify3D
|
|
||||||
outershell: FEATURE.OUTER_WALL,
|
|
||||||
innershell: FEATURE.INNER_WALL,
|
|
||||||
infill: FEATURE.SPARSE_INFILL,
|
|
||||||
solidlayer: FEATURE.SOLID_INFILL,
|
|
||||||
raft: FEATURE.SUPPORT,
|
|
||||||
ooze: FEATURE.PRIME_TOWER
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export function featureFromSlicerString(value) {
|
|
||||||
if (!value) return FEATURE.UNKNOWN
|
|
||||||
const key = String(value)
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[\s\-_]+/g, '')
|
|
||||||
return FEATURE_LOOKUP.get(key) ?? FEATURE.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
// Travel and wipe moves carry no material and are drawn as lines.
|
|
||||||
export function isLineFeature(type) {
|
|
||||||
return type <= FEATURE.TRAVEL || type === FEATURE.WIPE
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isExtrusionFeature(type) {
|
|
||||||
return type > FEATURE.TRAVEL && type !== FEATURE.WIPE
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ALL_TYPES_MASK = (1 << FEATURE_COUNT) - 1
|
|
||||||
export const LINE_TYPES_MASK = (1 << FEATURE.TRAVEL) | (1 << FEATURE.WIPE)
|
|
||||||
export const EXTRUSION_TYPES_MASK = ALL_TYPES_MASK & ~LINE_TYPES_MASK & ~1
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,169 +0,0 @@
|
|||||||
import { FEATURE } from './featureTypes.js'
|
|
||||||
|
|
||||||
// Simplified Klipper-style motion planner. For every segment it computes the
|
|
||||||
// trapezoid the firmware would run given the requested feedrate, the active
|
|
||||||
// acceleration and the junction limits between consecutive moves, which yields
|
|
||||||
// the speed the toolhead actually reaches and how long each segment takes.
|
|
||||||
//
|
|
||||||
// It ignores extruder-specific limits, minimum cruise ratio smoothing and
|
|
||||||
// input shaper, so times are approximate but relative values are faithful.
|
|
||||||
|
|
||||||
const SQRT2_MINUS_1 = Math.SQRT2 - 1
|
|
||||||
|
|
||||||
export function planMotion(store, options = {}) {
|
|
||||||
const {
|
|
||||||
maxVelocity = 500,
|
|
||||||
squareCornerVelocity = 5,
|
|
||||||
defaultAccel = 3000
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const n = store.count
|
|
||||||
const positions = store.positions
|
|
||||||
const feed = store.feed
|
|
||||||
const accelField = store.accel
|
|
||||||
const type = store.type
|
|
||||||
|
|
||||||
const distance = new Float32Array(n)
|
|
||||||
const deltaV2 = new Float32Array(n)
|
|
||||||
const maxStartV2 = new Float32Array(n)
|
|
||||||
const maxCruiseV2 = new Float32Array(n)
|
|
||||||
const startV2 = new Float32Array(n)
|
|
||||||
const actualSpeed = new Float32Array(n)
|
|
||||||
const segmentTime = new Float32Array(n)
|
|
||||||
|
|
||||||
const scv2 = squareCornerVelocity * squareCornerVelocity
|
|
||||||
|
|
||||||
let prevValid = false
|
|
||||||
let prevDx = 0
|
|
||||||
let prevDy = 0
|
|
||||||
let prevDz = 0
|
|
||||||
let prevAccel = defaultAccel
|
|
||||||
let prevJunctionDeviation = 0
|
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const p = i * 3
|
|
||||||
const dx = positions[p + 3] - positions[p]
|
|
||||||
const dy = positions[p + 4] - positions[p + 1]
|
|
||||||
const dz = positions[p + 5] - positions[p + 2]
|
|
||||||
const d = Math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
||||||
distance[i] = d
|
|
||||||
|
|
||||||
if (type[i] === FEATURE.NONE || d <= 0) {
|
|
||||||
// Position jump: no motion, acts as a full stop.
|
|
||||||
deltaV2[i] = 0
|
|
||||||
maxStartV2[i] = 0
|
|
||||||
maxCruiseV2[i] = 0
|
|
||||||
prevValid = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let accel = accelField[i]
|
|
||||||
if (!(accel > 0)) accel = defaultAccel
|
|
||||||
let vReq = feed[i]
|
|
||||||
if (!(vReq > 0)) vReq = maxVelocity
|
|
||||||
if (vReq > maxVelocity) vReq = maxVelocity
|
|
||||||
const cruiseV2 = vReq * vReq
|
|
||||||
maxCruiseV2[i] = cruiseV2
|
|
||||||
deltaV2[i] = 2 * d * accel
|
|
||||||
const junctionDeviation = (scv2 * SQRT2_MINUS_1) / accel
|
|
||||||
|
|
||||||
const ux = dx / d
|
|
||||||
const uy = dy / d
|
|
||||||
const uz = dz / d
|
|
||||||
|
|
||||||
if (!prevValid) {
|
|
||||||
maxStartV2[i] = 0
|
|
||||||
} else {
|
|
||||||
let cosTheta = -(ux * prevDx + uy * prevDy + uz * prevDz)
|
|
||||||
if (cosTheta > 0.999999) {
|
|
||||||
maxStartV2[i] = 0
|
|
||||||
} else {
|
|
||||||
if (cosTheta < -0.999999) cosTheta = -0.999999
|
|
||||||
const sinThetaD2 = Math.sqrt(0.5 * (1 - cosTheta))
|
|
||||||
const rJd = sinThetaD2 / (1 - sinThetaD2)
|
|
||||||
let limit = rJd * junctionDeviation * accel
|
|
||||||
const prevLimit = rJd * prevJunctionDeviation * prevAccel
|
|
||||||
if (prevLimit < limit) limit = prevLimit
|
|
||||||
if (cruiseV2 < limit) limit = cruiseV2
|
|
||||||
const prevCruise = maxCruiseV2[i - 1]
|
|
||||||
if (prevCruise < limit) limit = prevCruise
|
|
||||||
const reachable = maxStartV2[i - 1] + deltaV2[i - 1]
|
|
||||||
if (reachable < limit) limit = reachable
|
|
||||||
maxStartV2[i] = limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prevValid = true
|
|
||||||
prevDx = ux
|
|
||||||
prevDy = uy
|
|
||||||
prevDz = uz
|
|
||||||
prevAccel = accel
|
|
||||||
prevJunctionDeviation = junctionDeviation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward pass: nothing may end faster than it can decelerate into the
|
|
||||||
// next junction (the file ends at rest).
|
|
||||||
let nextStartV2 = 0
|
|
||||||
for (let i = n - 1; i >= 0; i--) {
|
|
||||||
const reachable = nextStartV2 + deltaV2[i]
|
|
||||||
const v2 = maxStartV2[i] < reachable ? maxStartV2[i] : reachable
|
|
||||||
startV2[i] = v2
|
|
||||||
nextStartV2 = v2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward pass and trapezoid generation.
|
|
||||||
let currentStartV2 = 0
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const d = distance[i]
|
|
||||||
if (type[i] === FEATURE.NONE || d <= 0) {
|
|
||||||
currentStartV2 = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let sV2 = startV2[i] < currentStartV2 ? startV2[i] : currentStartV2
|
|
||||||
const nextS = i + 1 < n ? startV2[i + 1] : 0
|
|
||||||
const reachableEnd = sV2 + deltaV2[i]
|
|
||||||
let eV2 = nextS < reachableEnd ? nextS : reachableEnd
|
|
||||||
if (eV2 < 0) eV2 = 0
|
|
||||||
let cV2 = (sV2 + eV2 + deltaV2[i]) * 0.5
|
|
||||||
if (cV2 > maxCruiseV2[i]) cV2 = maxCruiseV2[i]
|
|
||||||
if (cV2 < sV2) cV2 = sV2
|
|
||||||
if (cV2 < eV2) cV2 = eV2
|
|
||||||
|
|
||||||
let accel = accelField[i]
|
|
||||||
if (!(accel > 0)) accel = defaultAccel
|
|
||||||
const sV = Math.sqrt(sV2)
|
|
||||||
const eV = Math.sqrt(eV2)
|
|
||||||
const cV = Math.sqrt(cV2)
|
|
||||||
const halfInvAccel = 0.5 / accel
|
|
||||||
const accelD = (cV2 - sV2) * halfInvAccel
|
|
||||||
const decelD = (cV2 - eV2) * halfInvAccel
|
|
||||||
let cruiseD = d - accelD - decelD
|
|
||||||
if (cruiseD < 0) cruiseD = 0
|
|
||||||
const t =
|
|
||||||
(cV - sV) / accel + (cV > 0 ? cruiseD / cV : 0) + (cV - eV) / accel
|
|
||||||
|
|
||||||
actualSpeed[i] = cV
|
|
||||||
segmentTime[i] = t
|
|
||||||
currentStartV2 = eV2
|
|
||||||
}
|
|
||||||
|
|
||||||
return { actualSpeed, segmentTime }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sums segment times per layer and expands them back to one value per
|
|
||||||
// segment so the shader can colour by layer time with a plain attribute.
|
|
||||||
export function computeLayerTimes(store, segmentTime, layers) {
|
|
||||||
const n = store.count
|
|
||||||
const layerTime = new Float32Array(n)
|
|
||||||
const perLayer = new Float32Array(layers.length)
|
|
||||||
let total = 0
|
|
||||||
for (let l = 0; l < layers.length; l++) {
|
|
||||||
const { start, end } = layers[l]
|
|
||||||
let sum = 0
|
|
||||||
for (let i = start; i < end && i < n; i++) sum += segmentTime[i]
|
|
||||||
perLayer[l] = sum
|
|
||||||
total += sum
|
|
||||||
for (let i = start; i < end && i < n; i++) layerTime[i] = sum
|
|
||||||
}
|
|
||||||
return { layerTime, perLayer, total }
|
|
||||||
}
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
// Structure-of-arrays storage for parsed G-code segments.
|
|
||||||
//
|
|
||||||
// Segment i runs from positions[i] to positions[i + 1], so the path is a
|
|
||||||
// single shared polyline and every other field is one entry per segment. The
|
|
||||||
// same class is used by the parser worker (filling it) and the main thread
|
|
||||||
// (receiving transferred chunks and feeding GPU buffers).
|
|
||||||
|
|
||||||
export const SEGMENT_FIELDS = [
|
|
||||||
['type', Uint8Array],
|
|
||||||
['tool', Uint8Array],
|
|
||||||
['fan', Uint8Array],
|
|
||||||
['layer', Uint16Array],
|
|
||||||
['temp', Uint16Array],
|
|
||||||
['width', Float32Array],
|
|
||||||
['height', Float32Array],
|
|
||||||
['feed', Float32Array],
|
|
||||||
['accel', Float32Array],
|
|
||||||
['offset', Uint32Array]
|
|
||||||
]
|
|
||||||
|
|
||||||
// Filled after the planner pass, not part of streamed chunks.
|
|
||||||
export const PLANNER_FIELDS = [
|
|
||||||
['actualSpeed', Float32Array],
|
|
||||||
['segmentTime', Float32Array],
|
|
||||||
['layerTime', Float32Array]
|
|
||||||
]
|
|
||||||
|
|
||||||
const ALL_FIELDS = [...SEGMENT_FIELDS, ...PLANNER_FIELDS]
|
|
||||||
|
|
||||||
export default class SegmentStore {
|
|
||||||
constructor(capacity = 1024) {
|
|
||||||
this.count = 0
|
|
||||||
this.capacity = 0
|
|
||||||
// Bumped whenever the backing arrays are reallocated so GPU-side
|
|
||||||
// consumers know to rebuild their attribute bindings.
|
|
||||||
this.generation = 0
|
|
||||||
this.positions = null
|
|
||||||
this.plannerReady = false
|
|
||||||
this.allocate(Math.max(16, capacity | 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
allocate(capacity) {
|
|
||||||
const oldCount = this.count
|
|
||||||
const oldPositions = this.positions
|
|
||||||
const nextPositions = new Float32Array((capacity + 1) * 3)
|
|
||||||
if (oldPositions) {
|
|
||||||
nextPositions.set(oldPositions.subarray(0, (oldCount + 1) * 3))
|
|
||||||
}
|
|
||||||
this.positions = nextPositions
|
|
||||||
for (const [name, Type] of ALL_FIELDS) {
|
|
||||||
const next = new Type(capacity)
|
|
||||||
if (this[name]) next.set(this[name].subarray(0, oldCount))
|
|
||||||
this[name] = next
|
|
||||||
}
|
|
||||||
this.capacity = capacity
|
|
||||||
this.generation++
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureCapacity(required) {
|
|
||||||
if (required <= this.capacity) return
|
|
||||||
let next = this.capacity
|
|
||||||
while (next < required) next = Math.ceil(next * 1.5) + 1024
|
|
||||||
this.allocate(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Worker side: append one segment. The start point is written here and the
|
|
||||||
// end point is whatever the next push (or setEnd) writes.
|
|
||||||
push(
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
z,
|
|
||||||
type,
|
|
||||||
tool,
|
|
||||||
fan,
|
|
||||||
layer,
|
|
||||||
temp,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
feed,
|
|
||||||
accel,
|
|
||||||
offset
|
|
||||||
) {
|
|
||||||
const i = this.count
|
|
||||||
if (i + 1 > this.capacity) this.ensureCapacity(i + 1)
|
|
||||||
const p = i * 3
|
|
||||||
this.positions[p] = x
|
|
||||||
this.positions[p + 1] = y
|
|
||||||
this.positions[p + 2] = z
|
|
||||||
this.type[i] = type
|
|
||||||
this.tool[i] = tool
|
|
||||||
this.fan[i] = fan
|
|
||||||
this.layer[i] = layer > 65535 ? 65535 : layer
|
|
||||||
this.temp[i] = temp < 0 ? 0 : temp > 65535 ? 65535 : temp
|
|
||||||
this.width[i] = width
|
|
||||||
this.height[i] = height
|
|
||||||
this.feed[i] = feed
|
|
||||||
this.accel[i] = accel
|
|
||||||
this.offset[i] = offset
|
|
||||||
this.count = i + 1
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
|
|
||||||
setEnd(x, y, z) {
|
|
||||||
const p = this.count * 3
|
|
||||||
this.positions[p] = x
|
|
||||||
this.positions[p + 1] = y
|
|
||||||
this.positions[p + 2] = z
|
|
||||||
}
|
|
||||||
|
|
||||||
// Worker side: copy segments [from, to) into transferable buffers.
|
|
||||||
sliceChunk(from, to) {
|
|
||||||
const count = to - from
|
|
||||||
const chunk = { start: from, count }
|
|
||||||
const transfer = []
|
|
||||||
// Start points for every segment in the range plus the end of the last
|
|
||||||
// one, which is just the next start (or the final end point).
|
|
||||||
const positions = this.positions.slice(from * 3, (to + 1) * 3)
|
|
||||||
chunk.positions = positions
|
|
||||||
transfer.push(positions.buffer)
|
|
||||||
for (const [name] of SEGMENT_FIELDS) {
|
|
||||||
const copy = this[name].slice(from, to)
|
|
||||||
chunk[name] = copy
|
|
||||||
transfer.push(copy.buffer)
|
|
||||||
}
|
|
||||||
return { chunk, transfer }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main thread side: append a chunk produced by sliceChunk.
|
|
||||||
appendChunk(chunk) {
|
|
||||||
const { start, count } = chunk
|
|
||||||
if (start !== this.count) {
|
|
||||||
throw new Error(
|
|
||||||
`SegmentStore chunk out of order (expected ${this.count}, got ${start})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
this.ensureCapacity(start + count)
|
|
||||||
this.positions.set(chunk.positions, start * 3)
|
|
||||||
for (const [name] of SEGMENT_FIELDS) {
|
|
||||||
this[name].set(chunk[name], start)
|
|
||||||
}
|
|
||||||
this.count = start + count
|
|
||||||
}
|
|
||||||
|
|
||||||
applyPlanner({ actualSpeed, segmentTime, layerTime }) {
|
|
||||||
if (actualSpeed) this.actualSpeed.set(actualSpeed.subarray(0, this.count))
|
|
||||||
if (segmentTime) this.segmentTime.set(segmentTime.subarray(0, this.count))
|
|
||||||
if (layerTime) this.layerTime.set(layerTime.subarray(0, this.count))
|
|
||||||
this.plannerReady = true
|
|
||||||
}
|
|
||||||
|
|
||||||
getStart(i, out) {
|
|
||||||
const p = i * 3
|
|
||||||
out[0] = this.positions[p]
|
|
||||||
out[1] = this.positions[p + 1]
|
|
||||||
out[2] = this.positions[p + 2]
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
getEnd(i, out) {
|
|
||||||
return this.getStart(i + 1, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Index of the last segment whose source line starts at or before the given
|
|
||||||
// byte offset, or -1 when the offset precedes every segment.
|
|
||||||
segmentAtByteOffset(byteOffset) {
|
|
||||||
const offsets = this.offset
|
|
||||||
let lo = 0
|
|
||||||
let hi = this.count - 1
|
|
||||||
let result = -1
|
|
||||||
while (lo <= hi) {
|
|
||||||
const mid = (lo + hi) >>> 1
|
|
||||||
if (offsets[mid] <= byteOffset) {
|
|
||||||
result = mid
|
|
||||||
lo = mid + 1
|
|
||||||
} else {
|
|
||||||
hi = mid - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,302 +0,0 @@
|
|||||||
// Maps live printer state onto a segment index for progress rendering.
|
|
||||||
//
|
|
||||||
// The printer bridge publishes motion and virtual_sdcard state together on a
|
|
||||||
// 500ms timer. Between those samples the toolhead is moved along the parsed
|
|
||||||
// toolpath, with each segment's share of the gap taken from the planned move
|
|
||||||
// time (or the commanded feed before planning finishes).
|
|
||||||
|
|
||||||
import { FEATURE } from './featureTypes.js'
|
|
||||||
|
|
||||||
const REFINE_WINDOW = 400
|
|
||||||
const MAX_SNAP_DISTANCE = 3 // mm
|
|
||||||
|
|
||||||
// Prior matches farmcontrol-server's printer event interval. Measured gaps
|
|
||||||
// replace it once two samples have arrived.
|
|
||||||
export const DEFAULT_UPDATE_INTERVAL_MS = 500
|
|
||||||
const MIN_UPDATE_INTERVAL_MS = 200
|
|
||||||
const MAX_UPDATE_INTERVAL_MS = 1000
|
|
||||||
const MIN_SAMPLE_GAP_MS = 80
|
|
||||||
const MAX_SAMPLE_GAP_MS = 4000
|
|
||||||
|
|
||||||
const MAX_SPAN_SEGMENTS = 2500
|
|
||||||
const MAX_SPAN_LENGTH = 500 // mm
|
|
||||||
const FALLBACK_SPEED = 50 // mm/s when a move has no usable feed
|
|
||||||
|
|
||||||
export function nextUpdateInterval(previousMs, gapMs) {
|
|
||||||
const previous =
|
|
||||||
previousMs > 0 ? previousMs : DEFAULT_UPDATE_INTERVAL_MS
|
|
||||||
if (!(gapMs >= MIN_SAMPLE_GAP_MS) || gapMs > MAX_SAMPLE_GAP_MS) {
|
|
||||||
return previous
|
|
||||||
}
|
|
||||||
const blended = previous * 0.6 + gapMs * 0.4
|
|
||||||
if (blended < MIN_UPDATE_INTERVAL_MS) return MIN_UPDATE_INTERVAL_MS
|
|
||||||
if (blended > MAX_UPDATE_INTERVAL_MS) return MAX_UPDATE_INTERVAL_MS
|
|
||||||
return blended
|
|
||||||
}
|
|
||||||
|
|
||||||
// Klipper reports virtual_sdcard.file_position in bytes. Our offsets are
|
|
||||||
// bytes into the same file, so this is exact unless the printer's copy has
|
|
||||||
// a different size, in which case we fall back to proportional scaling.
|
|
||||||
export function fileOffsetForPrinter(
|
|
||||||
filePosition,
|
|
||||||
printerFileSize,
|
|
||||||
byteLength
|
|
||||||
) {
|
|
||||||
if (!(filePosition >= 0)) return 0
|
|
||||||
if (
|
|
||||||
printerFileSize > 0 &&
|
|
||||||
byteLength > 0 &&
|
|
||||||
Math.abs(printerFileSize - byteLength) > 64
|
|
||||||
) {
|
|
||||||
return (filePosition / printerFileSize) * byteLength
|
|
||||||
}
|
|
||||||
return filePosition
|
|
||||||
}
|
|
||||||
|
|
||||||
// Klipper's lookahead means file_position runs ahead of physical motion.
|
|
||||||
// Scan back from the file-derived segment for the segment closest to the
|
|
||||||
// live toolhead position and return where along it the head is.
|
|
||||||
export function refineSegmentByPosition(store, segment, position) {
|
|
||||||
const count = store.count
|
|
||||||
if (count === 0) return { segment: -1, t: 1, snapped: false }
|
|
||||||
let seg = Math.min(Math.max(segment, 0), count - 1)
|
|
||||||
if (!position || position.length < 3) return { segment: seg, t: 1, snapped: false }
|
|
||||||
const [px, py, pz] = position
|
|
||||||
if (![px, py, pz].every(Number.isFinite)) {
|
|
||||||
return { segment: seg, t: 1, snapped: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
const positions = store.positions
|
|
||||||
const from = Math.max(0, seg - REFINE_WINDOW)
|
|
||||||
let bestSeg = -1
|
|
||||||
let bestT = 1
|
|
||||||
let bestD2 = Infinity
|
|
||||||
for (let i = seg; i >= from; i--) {
|
|
||||||
const p = i * 3
|
|
||||||
const ax = positions[p]
|
|
||||||
const ay = positions[p + 1]
|
|
||||||
const az = positions[p + 2]
|
|
||||||
const bx = positions[p + 3]
|
|
||||||
const by = positions[p + 4]
|
|
||||||
const bz = positions[p + 5]
|
|
||||||
const dx = bx - ax
|
|
||||||
const dy = by - ay
|
|
||||||
const dz = bz - az
|
|
||||||
const len2 = dx * dx + dy * dy + dz * dz
|
|
||||||
let t = 0
|
|
||||||
if (len2 > 1e-12) {
|
|
||||||
t = ((px - ax) * dx + (py - ay) * dy + (pz - az) * dz) / len2
|
|
||||||
if (t < 0) t = 0
|
|
||||||
else if (t > 1) t = 1
|
|
||||||
}
|
|
||||||
const cx = ax + dx * t - px
|
|
||||||
const cy = ay + dy * t - py
|
|
||||||
const cz = az + dz * t - pz
|
|
||||||
const d2 = cx * cx + cy * cy + cz * cz
|
|
||||||
if (d2 < bestD2) {
|
|
||||||
bestD2 = d2
|
|
||||||
bestSeg = i
|
|
||||||
bestT = t
|
|
||||||
if (d2 < 1e-6) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bestSeg < 0 || bestD2 > MAX_SNAP_DISTANCE * MAX_SNAP_DISTANCE) {
|
|
||||||
return { segment: seg, t: 1, snapped: false }
|
|
||||||
}
|
|
||||||
return { segment: bestSeg, t: bestT, snapped: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pointOnSegment(store, segment, t, out = [0, 0, 0]) {
|
|
||||||
const count = store.count
|
|
||||||
if (count === 0) return null
|
|
||||||
const i = Math.min(Math.max(segment, 0), count - 1)
|
|
||||||
const p = i * 3
|
|
||||||
const positions = store.positions
|
|
||||||
out[0] = positions[p] + (positions[p + 3] - positions[p]) * t
|
|
||||||
out[1] = positions[p + 1] + (positions[p + 4] - positions[p + 1]) * t
|
|
||||||
out[2] = positions[p + 2] + (positions[p + 5] - positions[p + 2]) * t
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export function layerIndexForSegment(layers, segment) {
|
|
||||||
let lo = 0
|
|
||||||
let hi = layers.length - 1
|
|
||||||
let result = -1
|
|
||||||
while (lo <= hi) {
|
|
||||||
const mid = (lo + hi) >>> 1
|
|
||||||
if (layers[mid].start <= segment) {
|
|
||||||
result = mid
|
|
||||||
lo = mid + 1
|
|
||||||
} else {
|
|
||||||
hi = mid - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteXYZ(position) {
|
|
||||||
if (!Array.isArray(position) || position.length < 3) return null
|
|
||||||
const x = position[0]
|
|
||||||
const y = position[1]
|
|
||||||
const z = position[2]
|
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return [x, y, z]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function segmentLength(store, segment) {
|
|
||||||
const p = segment * 3
|
|
||||||
const positions = store.positions
|
|
||||||
const dx = positions[p + 3] - positions[p]
|
|
||||||
const dy = positions[p + 4] - positions[p + 1]
|
|
||||||
const dz = positions[p + 5] - positions[p + 2]
|
|
||||||
return Math.hypot(dx, dy, dz)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seconds to traverse `fraction` of a segment. Planned time already includes
|
|
||||||
// acceleration; feed (mm/s) is the fallback while the planner is still running.
|
|
||||||
export function segmentTravelSeconds(store, segment, fraction) {
|
|
||||||
if (!(fraction > 0)) return 0
|
|
||||||
if (store.plannerReady) {
|
|
||||||
const planned = store.segmentTime[segment]
|
|
||||||
if (planned > 0) return planned * fraction
|
|
||||||
}
|
|
||||||
const length = segmentLength(store, segment) * fraction
|
|
||||||
if (!(length > 0)) return 0
|
|
||||||
let speed = store.feed[segment]
|
|
||||||
if (!(speed > 0.2)) speed = FALLBACK_SPEED
|
|
||||||
return length / speed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where the printer is on the parsed path. The hot end follows this point
|
|
||||||
// rather than the raw sample, so motion stays on the toolpath.
|
|
||||||
export function resolveTrackSample(store, byteLength, gcodePosition, toolPosition) {
|
|
||||||
const live =
|
|
||||||
finiteXYZ(toolPosition) || finiteXYZ(gcodePosition?.position)
|
|
||||||
const offset = fileOffsetForPrinter(
|
|
||||||
gcodePosition?.filePosition,
|
|
||||||
gcodePosition?.fileSize,
|
|
||||||
byteLength
|
|
||||||
)
|
|
||||||
if (!store.count) {
|
|
||||||
if (!live) return null
|
|
||||||
return {
|
|
||||||
segment: -1,
|
|
||||||
t: 1,
|
|
||||||
x: live[0],
|
|
||||||
y: live[1],
|
|
||||||
z: live[2],
|
|
||||||
mapped: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fileSegment = store.segmentAtByteOffset(offset)
|
|
||||||
if (fileSegment < 0) {
|
|
||||||
if (!live) return null
|
|
||||||
return {
|
|
||||||
segment: -1,
|
|
||||||
t: 1,
|
|
||||||
x: live[0],
|
|
||||||
y: live[1],
|
|
||||||
z: live[2],
|
|
||||||
mapped: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const refined = refineSegmentByPosition(store, fileSegment, live)
|
|
||||||
// A live position that isn't on the recent toolpath (jog, home, lookahead
|
|
||||||
// miss) stays at the reported coordinates. Following the file byte instead
|
|
||||||
// would pull the nozzle ahead of the printer.
|
|
||||||
if (live && !refined.snapped) {
|
|
||||||
return {
|
|
||||||
segment: refined.segment,
|
|
||||||
t: refined.t,
|
|
||||||
x: live[0],
|
|
||||||
y: live[1],
|
|
||||||
z: live[2],
|
|
||||||
mapped: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const point = pointOnSegment(store, refined.segment, refined.t)
|
|
||||||
if (!point) {
|
|
||||||
if (!live) return null
|
|
||||||
return {
|
|
||||||
segment: -1,
|
|
||||||
t: 1,
|
|
||||||
x: live[0],
|
|
||||||
y: live[1],
|
|
||||||
z: live[2],
|
|
||||||
mapped: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
segment: refined.segment,
|
|
||||||
t: refined.t,
|
|
||||||
x: point[0],
|
|
||||||
y: point[1],
|
|
||||||
z: point[2],
|
|
||||||
mapped: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pieces of toolpath from (fromSeg, fromT) to (toSeg, toT), weighted by how
|
|
||||||
// long the firmware spends on each move. Null when the gap is a teleport or
|
|
||||||
// too large to be one update of motion.
|
|
||||||
export function buildPathSpan(store, fromSeg, fromT, toSeg, toT) {
|
|
||||||
if (fromSeg < 0 || toSeg < 0) return null
|
|
||||||
if (toSeg < fromSeg || toSeg - fromSeg > MAX_SPAN_SEGMENTS) return null
|
|
||||||
if (toSeg === fromSeg && toT < fromT - 1e-4) return null
|
|
||||||
|
|
||||||
const pieces = []
|
|
||||||
let total = 0
|
|
||||||
let length = 0
|
|
||||||
for (let i = fromSeg; i <= toSeg; i++) {
|
|
||||||
if (store.type[i] === FEATURE.NONE) return null
|
|
||||||
const t0 = i === fromSeg ? fromT : 0
|
|
||||||
const t1 = i === toSeg ? toT : 1
|
|
||||||
const fraction = t1 - t0
|
|
||||||
if (fraction < 1e-5) continue
|
|
||||||
const pieceLength = segmentLength(store, i) * fraction
|
|
||||||
if (!(pieceLength > 1e-6)) continue
|
|
||||||
const duration = segmentTravelSeconds(store, i, fraction)
|
|
||||||
if (!(duration > 0)) continue
|
|
||||||
total += duration
|
|
||||||
length += pieceLength
|
|
||||||
if (length > MAX_SPAN_LENGTH) return null
|
|
||||||
pieces.push({ segment: i, t0, t1, duration, end: total })
|
|
||||||
}
|
|
||||||
if (!pieces.length || !(total > 0)) return null
|
|
||||||
return { pieces, total, length }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function samplePathSpan(span, u, store, out = [0, 0, 0]) {
|
|
||||||
const pieces = span.pieces
|
|
||||||
const last = pieces.length - 1
|
|
||||||
let target = span.total * u
|
|
||||||
if (target < 0) target = 0
|
|
||||||
else if (target > span.total) target = span.total
|
|
||||||
let lo = 0
|
|
||||||
let hi = last
|
|
||||||
while (lo < hi) {
|
|
||||||
const mid = (lo + hi) >>> 1
|
|
||||||
if (pieces[mid].end < target) lo = mid + 1
|
|
||||||
else hi = mid
|
|
||||||
}
|
|
||||||
const piece = pieces[lo]
|
|
||||||
const start = piece.end - piece.duration
|
|
||||||
let local = 1
|
|
||||||
if (piece.duration > 1e-9) {
|
|
||||||
local = (target - start) / piece.duration
|
|
||||||
if (local < 0) local = 0
|
|
||||||
else if (local > 1) local = 1
|
|
||||||
}
|
|
||||||
const t = piece.t0 + (piece.t1 - piece.t0) * local
|
|
||||||
pointOnSegment(store, piece.segment, t, out)
|
|
||||||
return {
|
|
||||||
segment: piece.segment,
|
|
||||||
t,
|
|
||||||
x: out[0],
|
|
||||||
y: out[1],
|
|
||||||
z: out[2],
|
|
||||||
mapped: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import SegmentStore from './segmentStore.js'
|
|
||||||
|
|
||||||
const IDLE = {
|
|
||||||
status: 'idle',
|
|
||||||
progress: 0,
|
|
||||||
planning: false,
|
|
||||||
model: null,
|
|
||||||
revision: 0,
|
|
||||||
error: null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createModel(size) {
|
|
||||||
const capacity = size > 0 ? Math.ceil(size / 28) : 65536
|
|
||||||
return {
|
|
||||||
size,
|
|
||||||
store: new SegmentStore(Math.min(capacity, 50_000_000)),
|
|
||||||
layers: [],
|
|
||||||
metadata: null,
|
|
||||||
stats: null,
|
|
||||||
bounds: null,
|
|
||||||
tools: [],
|
|
||||||
typeCounts: null,
|
|
||||||
totalTime: 0,
|
|
||||||
byteLength: 0,
|
|
||||||
lineCount: 0,
|
|
||||||
hasTypeComments: false,
|
|
||||||
done: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streams a G-code file through the parser worker. `model` is a stable
|
|
||||||
// object per load that is mutated as chunks arrive; `revision` increments
|
|
||||||
// on every change so consumers can resync without deep comparisons.
|
|
||||||
export default function useGCodeLoader(src) {
|
|
||||||
const [state, setState] = useState(IDLE)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!src) {
|
|
||||||
setState(IDLE)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
const worker = new Worker(new URL('./parser.worker.js', import.meta.url), {
|
|
||||||
type: 'module'
|
|
||||||
})
|
|
||||||
let model = createModel(0)
|
|
||||||
setState({
|
|
||||||
status: 'loading',
|
|
||||||
progress: 0,
|
|
||||||
planning: false,
|
|
||||||
model,
|
|
||||||
revision: 1,
|
|
||||||
error: null
|
|
||||||
})
|
|
||||||
|
|
||||||
worker.onmessage = (event) => {
|
|
||||||
if (cancelled) return
|
|
||||||
const message = event.data
|
|
||||||
switch (message.type) {
|
|
||||||
case 'start': {
|
|
||||||
if (message.size > 0 && model.store.count === 0) {
|
|
||||||
model = createModel(message.size)
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
model,
|
|
||||||
revision: prev.revision + 1
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'progress': {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
progress: message.progress,
|
|
||||||
planning: Boolean(message.planning)
|
|
||||||
}))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'chunk': {
|
|
||||||
model.store.appendChunk(message.chunk)
|
|
||||||
setState((prev) => ({ ...prev, revision: prev.revision + 1 }))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'done': {
|
|
||||||
const { store } = model
|
|
||||||
store.setEnd(message.end[0], message.end[1], message.end[2])
|
|
||||||
store.applyPlanner(message)
|
|
||||||
model.layers = message.layers
|
|
||||||
model.metadata = message.metadata
|
|
||||||
model.stats = message.stats
|
|
||||||
model.bounds = message.bounds
|
|
||||||
model.tools = message.tools
|
|
||||||
model.typeCounts = message.typeCounts
|
|
||||||
model.totalTime = message.totalTime
|
|
||||||
model.byteLength = message.byteLength
|
|
||||||
model.lineCount = message.lineCount
|
|
||||||
model.hasTypeComments = message.hasTypeComments
|
|
||||||
model.done = true
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
status: 'ready',
|
|
||||||
progress: 1,
|
|
||||||
planning: false,
|
|
||||||
revision: prev.revision + 1
|
|
||||||
}))
|
|
||||||
worker.terminate()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'error': {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
status: 'error',
|
|
||||||
planning: false,
|
|
||||||
error: message.message || 'Failed to parse G-code'
|
|
||||||
}))
|
|
||||||
worker.terminate()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
worker.onerror = (event) => {
|
|
||||||
if (cancelled) return
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
status: 'error',
|
|
||||||
error: event?.message || 'G-code worker failed'
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
worker.postMessage({ type: 'load', src })
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
worker.terminate()
|
|
||||||
}
|
|
||||||
}, [src])
|
|
||||||
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
@ -218,23 +218,6 @@ const NewDocumentJob = ({ onOk, defaultValues = {} }) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Word',
|
|
||||||
key: 'docx',
|
|
||||||
onClick: () => {
|
|
||||||
setDownloading(true)
|
|
||||||
downloadTemplate(
|
|
||||||
objectData.documentTemplate._id,
|
|
||||||
objectData.documentTemplate.content,
|
|
||||||
toTemplateObjectData(objectData.objects),
|
|
||||||
fileName,
|
|
||||||
'docx',
|
|
||||||
() => {
|
|
||||||
setDownloading(false)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useContext, useEffect, useCallback, useRef } from 'react'
|
import { useState, useContext, useEffect, useCallback } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import WizardView from '../../common/WizardView'
|
import WizardView from '../../common/WizardView'
|
||||||
import { ApiServerContext } from '../../context/ApiServerContext'
|
import { ApiServerContext } from '../../context/ApiServerContext'
|
||||||
@ -6,17 +6,7 @@ import { Descriptions, Flex, Typography } from 'antd'
|
|||||||
import ObjectTable from '../../common/ObjectTable'
|
import ObjectTable from '../../common/ObjectTable'
|
||||||
import { getModelByName } from '../../../../database/ObjectModels.js'
|
import { getModelByName } from '../../../../database/ObjectModels.js'
|
||||||
import ObjectProperty from '../../common/ObjectProperty'
|
import ObjectProperty from '../../common/ObjectProperty'
|
||||||
import { calculateModelComputedEntries } from '../../utils/Utils'
|
|
||||||
import {
|
|
||||||
applyHydratedObjects,
|
|
||||||
applyRelatedObjectUpdates,
|
|
||||||
collectObjectPropertyRefs,
|
|
||||||
getReferenceId,
|
|
||||||
getRelatedObjectSubscriptionKey
|
|
||||||
} from '../../utils/objectPropertyRefs'
|
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
const DeployJob = ({
|
const DeployJob = ({
|
||||||
onOk,
|
onOk,
|
||||||
objectData = undefined,
|
objectData = undefined,
|
||||||
@ -24,45 +14,12 @@ const DeployJob = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [deployLoading, setDeployLoading] = useState(false)
|
const [deployLoading, setDeployLoading] = useState(false)
|
||||||
const [job, setJob] = useState(objectData)
|
const [job, setJob] = useState(objectData)
|
||||||
const jobRef = useRef(job)
|
|
||||||
const [deployedSubJobsCount, setDeployedSubJobsCount] = useState(0)
|
const [deployedSubJobsCount, setDeployedSubJobsCount] = useState(0)
|
||||||
const [subJobsCount, setSubJobsCount] = useState(999)
|
const [subJobsCount, setSubJobsCount] = useState(999)
|
||||||
const [allPrintersOnline, setAllPrintersOnline] = useState(false)
|
const [allPrintersOnline, setAllPrintersOnline] = useState(false)
|
||||||
const {
|
const { sendObjectAction, fetchObjects } = useContext(ApiServerContext)
|
||||||
sendObjectAction,
|
|
||||||
fetchObjects,
|
|
||||||
fetchObject,
|
|
||||||
subscribeToObjectUpdates,
|
|
||||||
connected
|
|
||||||
} = useContext(ApiServerContext)
|
|
||||||
const fetchObjectRef = useRef(fetchObject)
|
|
||||||
fetchObjectRef.current = fetchObject
|
|
||||||
|
|
||||||
const model = getModelByName('job')
|
|
||||||
const objectDataId = getReferenceId(objectData)
|
|
||||||
const objectDataRef = useRef(objectData)
|
|
||||||
objectDataRef.current = objectData
|
|
||||||
|
|
||||||
const commitJob = useCallback((value) => {
|
|
||||||
const next = typeof value === 'function' ? value(jobRef.current) : value
|
|
||||||
jobRef.current = next
|
|
||||||
setJob(next)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const calculateComputedValues = useCallback(
|
|
||||||
(currentData, modelDefinition, options) => {
|
|
||||||
return calculateModelComputedEntries(
|
|
||||||
currentData,
|
|
||||||
modelDefinition,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDeploy = useCallback(async () => {
|
const handleDeploy = useCallback(async () => {
|
||||||
if (!job?._id) return
|
|
||||||
|
|
||||||
setDeployLoading(true)
|
setDeployLoading(true)
|
||||||
var hasMore = true
|
var hasMore = true
|
||||||
var currentPage = 1
|
var currentPage = 1
|
||||||
@ -90,7 +47,7 @@ const DeployJob = ({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [job?._id, sendObjectAction, fetchObjects])
|
}, [job._id, sendObjectAction, fetchObjects])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deployedSubJobsCount == subJobsCount && deployLoading == true) {
|
if (deployedSubJobsCount == subJobsCount && deployLoading == true) {
|
||||||
@ -99,110 +56,8 @@ const DeployJob = ({
|
|||||||
}, [deployedSubJobsCount, subJobsCount, deployLoading, onOk])
|
}, [deployedSubJobsCount, subJobsCount, deployLoading, onOk])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
commitJob(objectDataRef.current)
|
if (objectData?.printers) {
|
||||||
}, [objectDataId, commitJob])
|
if (objectData.printers.every((printer) => printer.online)) {
|
||||||
|
|
||||||
const hydrationKey = collectObjectPropertyRefs(job, model)
|
|
||||||
.filter((ref) => !ref.hydrated)
|
|
||||||
.map((ref) => `${ref.objectType}:${ref.id.toLowerCase()}`)
|
|
||||||
.sort()
|
|
||||||
.join('|')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hydrationKey) return undefined
|
|
||||||
|
|
||||||
const refs = collectObjectPropertyRefs(jobRef.current, model).filter(
|
|
||||||
(ref) => !ref.hydrated
|
|
||||||
)
|
|
||||||
if (refs.length === 0) return undefined
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
const seen = new Set()
|
|
||||||
const uniqueRefs = []
|
|
||||||
refs.forEach((ref) => {
|
|
||||||
const key = `${ref.objectType}:${ref.id.toLowerCase()}`
|
|
||||||
if (seen.has(key)) return
|
|
||||||
seen.add(key)
|
|
||||||
uniqueRefs.push(ref)
|
|
||||||
})
|
|
||||||
|
|
||||||
const hydrate = async () => {
|
|
||||||
const fetchedByKey = new Map()
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
uniqueRefs.map(async (ref) => {
|
|
||||||
const fullObject = await fetchObjectRef.current(
|
|
||||||
ref.id,
|
|
||||||
ref.objectType
|
|
||||||
)
|
|
||||||
if (!getReferenceId(fullObject)) return
|
|
||||||
fetchedByKey.set(
|
|
||||||
`${ref.objectType}:${ref.id.toLowerCase()}`,
|
|
||||||
fullObject
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
const applied = applyHydratedObjects(
|
|
||||||
jobRef.current,
|
|
||||||
refs,
|
|
||||||
fetchedByKey,
|
|
||||||
model,
|
|
||||||
calculateComputedValues
|
|
||||||
)
|
|
||||||
if (!applied) return
|
|
||||||
|
|
||||||
commitJob(applied.next)
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrate()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [hydrationKey, model, calculateComputedValues, commitJob])
|
|
||||||
|
|
||||||
const relatedObjectKey = getRelatedObjectSubscriptionKey(job, model)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (connected !== true || !relatedObjectKey) return undefined
|
|
||||||
|
|
||||||
const unsubscribes = relatedObjectKey.split('|').map((entry) => {
|
|
||||||
const separator = entry.indexOf(':')
|
|
||||||
const objectType = entry.slice(0, separator)
|
|
||||||
const id = entry.slice(separator + 1)
|
|
||||||
|
|
||||||
return subscribeToObjectUpdates(id, objectType, (value) => {
|
|
||||||
const applied = applyRelatedObjectUpdates(
|
|
||||||
jobRef.current,
|
|
||||||
objectType,
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
model,
|
|
||||||
calculateComputedValues
|
|
||||||
)
|
|
||||||
if (!applied) return
|
|
||||||
commitJob(applied.next)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribes.forEach((unsubscribe) => unsubscribe?.())
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
connected,
|
|
||||||
relatedObjectKey,
|
|
||||||
subscribeToObjectUpdates,
|
|
||||||
model,
|
|
||||||
calculateComputedValues,
|
|
||||||
commitJob
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (job?.printers) {
|
|
||||||
if (job.printers.every((printer) => printer.online)) {
|
|
||||||
setAllPrintersOnline(true)
|
setAllPrintersOnline(true)
|
||||||
} else {
|
} else {
|
||||||
setAllPrintersOnline(false)
|
setAllPrintersOnline(false)
|
||||||
@ -210,7 +65,7 @@ const DeployJob = ({
|
|||||||
} else {
|
} else {
|
||||||
setAllPrintersOnline(false)
|
setAllPrintersOnline(false)
|
||||||
}
|
}
|
||||||
}, [job])
|
}, [objectData])
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
@ -243,13 +98,13 @@ const DeployJob = ({
|
|||||||
<ObjectProperty
|
<ObjectProperty
|
||||||
type={'object'}
|
type={'object'}
|
||||||
name={'job'}
|
name={'job'}
|
||||||
objectData={{ job }}
|
objectData={{ job: objectData }}
|
||||||
value={(object) => object.job}
|
value={(object) => object.job}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
isEditing={true}
|
isEditing={true}
|
||||||
objectType={'job'}
|
objectType={'job'}
|
||||||
onChange={(newJob) => {
|
onChange={(newJob) => {
|
||||||
commitJob(newJob)
|
setJob(newJob)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -276,7 +131,7 @@ const DeployJob = ({
|
|||||||
<ObjectProperty
|
<ObjectProperty
|
||||||
type={'objectList'}
|
type={'objectList'}
|
||||||
name={'printers'}
|
name={'printers'}
|
||||||
objectData={{ printers: job?.printers }}
|
objectData={{ printers: objectData.printers }}
|
||||||
value={(object) => object.printers}
|
value={(object) => object.printers}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
isEditing={true}
|
isEditing={true}
|
||||||
@ -296,9 +151,7 @@ const DeployJob = ({
|
|||||||
printer: false,
|
printer: false,
|
||||||
job: false
|
job: false
|
||||||
}}
|
}}
|
||||||
masterFilter={{
|
masterFilter={{ job: getModelByName('job').prefix + ':' + job?._id }}
|
||||||
job: getModelByName('job').prefix + ':' + job?._id
|
|
||||||
}}
|
|
||||||
size={'small'}
|
size={'small'}
|
||||||
showActions={!slicerIntegration}
|
showActions={!slicerIntegration}
|
||||||
/>
|
/>
|
||||||
@ -311,7 +164,7 @@ const DeployJob = ({
|
|||||||
<WizardView
|
<WizardView
|
||||||
steps={steps}
|
steps={steps}
|
||||||
loading={deployLoading}
|
loading={deployLoading}
|
||||||
formValid={job != undefined && allPrintersOnline}
|
formValid={objectData != undefined && allPrintersOnline}
|
||||||
title='Deploy Job'
|
title='Deploy Job'
|
||||||
showSteps={false}
|
showSteps={false}
|
||||||
showSkipButton={slicerIntegration}
|
showSkipButton={slicerIntegration}
|
||||||
|
|||||||
@ -50,7 +50,6 @@ const NewJob = ({
|
|||||||
visibleProperties={{
|
visibleProperties={{
|
||||||
_id: false,
|
_id: false,
|
||||||
_reference: false,
|
_reference: false,
|
||||||
name: false,
|
|
||||||
createdAt: false,
|
createdAt: false,
|
||||||
updatedAt: false,
|
updatedAt: false,
|
||||||
startedAt: false,
|
startedAt: false,
|
||||||
|
|||||||
@ -22,13 +22,11 @@ import MultiMotionIcon from '../../../Icons/MultiMotionIcon.jsx'
|
|||||||
import SettingsIcon from '../../../Icons/SettingsIcon.jsx'
|
import SettingsIcon from '../../../Icons/SettingsIcon.jsx'
|
||||||
|
|
||||||
import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel.jsx'
|
import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel.jsx'
|
||||||
import PrinterGCodeFilePanel from '../../common/PrinterGCodeFilePanel.jsx'
|
|
||||||
import PrinterPositionPanel from '../../common/PrinterPositionPanel.jsx'
|
import PrinterPositionPanel from '../../common/PrinterPositionPanel.jsx'
|
||||||
import PrinterMovementPanel from '../../common/PrinterMovementPanel.jsx'
|
import PrinterMovementPanel from '../../common/PrinterMovementPanel.jsx'
|
||||||
import PrinterMiscPanel from '../../common/PrinterMiscPanel.jsx'
|
import PrinterMiscPanel from '../../common/PrinterMiscPanel.jsx'
|
||||||
import JobIcon from '../../../Icons/JobIcon.jsx'
|
import JobIcon from '../../../Icons/JobIcon.jsx'
|
||||||
import SubJobIcon from '../../../Icons/SubJobIcon.jsx'
|
import SubJobIcon from '../../../Icons/SubJobIcon.jsx'
|
||||||
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
|
|
||||||
import FilamentStockIcon from '../../../Icons/FilamentStockIcon.jsx'
|
import FilamentStockIcon from '../../../Icons/FilamentStockIcon.jsx'
|
||||||
import MissingPlaceholder from '../../common/MissingPlaceholder.jsx'
|
import MissingPlaceholder from '../../common/MissingPlaceholder.jsx'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
@ -40,7 +38,6 @@ import PrinterControlButtons from '../../common/PrinterControlButtons.jsx'
|
|||||||
import PrinterQueue from '../../common/PrinterQueue.jsx'
|
import PrinterQueue from '../../common/PrinterQueue.jsx'
|
||||||
import ListIcon from '../../../Icons/ListIcon.jsx'
|
import ListIcon from '../../../Icons/ListIcon.jsx'
|
||||||
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
|
import { useObjectNavigationTabPage } from '../../hooks/useObjectNavigationTabPage'
|
||||||
import { getReferenceId } from '../../utils/objectPropertyRefs'
|
|
||||||
|
|
||||||
const log = loglevel.getLogger('ControlPrinter')
|
const log = loglevel.getLogger('ControlPrinter')
|
||||||
log.setLevel(config.logLevel)
|
log.setLevel(config.logLevel)
|
||||||
@ -61,8 +58,7 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
{
|
{
|
||||||
printer: true,
|
printer: true,
|
||||||
job: true,
|
job: true,
|
||||||
subJob: true,
|
subjob: true,
|
||||||
gcodeFile: true,
|
|
||||||
queue: true,
|
queue: true,
|
||||||
filamentStock: true,
|
filamentStock: true,
|
||||||
temperature: true,
|
temperature: true,
|
||||||
@ -134,67 +130,53 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
)
|
)
|
||||||
useDashboardObjectTools(currentObjectTools)
|
useDashboardObjectTools(currentObjectTools)
|
||||||
|
|
||||||
const objectInfoHyperlinkProps = slicerIntegration
|
const actions = {
|
||||||
? { showHyperlink: false, objectPropertyProps: { showHyperlink: false } }
|
edit: () => {
|
||||||
: {}
|
objectFormRef?.current.startEditing()
|
||||||
|
return false
|
||||||
const currentJobId = getReferenceId(objectFormState.objectData?.currentJob)
|
},
|
||||||
const currentSubJobId = getReferenceId(
|
cancelEdit: () => {
|
||||||
objectFormState.objectData?.currentSubJob
|
objectFormRef?.current.cancelEditing()
|
||||||
)
|
return true
|
||||||
const currentFilamentStockId = getReferenceId(
|
},
|
||||||
objectFormState.objectData?.currentFilamentStock
|
finishEdit: () => {
|
||||||
)
|
objectFormRef?.current.handleUpdate()
|
||||||
|
return true
|
||||||
const actions = useMemo(
|
},
|
||||||
() => ({
|
startQueue: () => {
|
||||||
edit: () => {
|
if (connected == true) {
|
||||||
objectFormRef?.current.startEditing()
|
sendObjectAction(printerId, 'printer', {
|
||||||
return false
|
type: 'startQueue'
|
||||||
},
|
})
|
||||||
cancelEdit: () => {
|
|
||||||
objectFormRef?.current.cancelEditing()
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
finishEdit: () => {
|
|
||||||
objectFormRef?.current.handleUpdate()
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
startQueue: () => {
|
|
||||||
if (connected == true) {
|
|
||||||
sendObjectAction(printerId, 'printer', {
|
|
||||||
type: 'startQueue'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
pauseJob: () => {
|
|
||||||
if (connected == true) {
|
|
||||||
sendObjectAction(printerId, 'printer', {
|
|
||||||
type: 'pauseJob'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
resumeJob: () => {
|
|
||||||
if (connected == true) {
|
|
||||||
sendObjectAction(printerId, 'printer', {
|
|
||||||
type: 'resumeJob'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
cancelJob: () => {
|
|
||||||
if (connected == true) {
|
|
||||||
sendObjectAction(printerId, 'printer', {
|
|
||||||
type: 'cancelJob'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}),
|
return true
|
||||||
[connected, printerId, sendObjectAction]
|
},
|
||||||
)
|
pauseJob: () => {
|
||||||
|
if (connected == true) {
|
||||||
|
sendObjectAction(printerId, 'printer', {
|
||||||
|
type: 'pauseJob'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
resumeJob: () => {
|
||||||
|
if (connected == true) {
|
||||||
|
sendObjectAction(printerId, 'printer', {
|
||||||
|
type: 'resumeJob'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
cancelJob: () => {
|
||||||
|
if (connected == true) {
|
||||||
|
sendObjectAction(printerId, 'printer', {
|
||||||
|
type: 'cancelJob'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sideBarItems = (
|
const sideBarItems = (
|
||||||
<Flex gap={'large'} vertical>
|
<Flex gap={'large'} vertical>
|
||||||
@ -306,10 +288,7 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
edit: false,
|
edit: false,
|
||||||
info: !slicerIntegration,
|
info: !slicerIntegration,
|
||||||
control: !slicerIntegration,
|
control: !slicerIntegration,
|
||||||
newPrinterProfile: !slicerIntegration,
|
newPrinterProfile: !slicerIntegration
|
||||||
list: !slicerIntegration,
|
|
||||||
duplicate: !slicerIntegration,
|
|
||||||
delete: !slicerIntegration
|
|
||||||
}}
|
}}
|
||||||
objectData={objectFormState.objectData}
|
objectData={objectFormState.objectData}
|
||||||
/>
|
/>
|
||||||
@ -328,10 +307,6 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
key: 'subJob',
|
key: 'subJob',
|
||||||
label: 'Sub Job'
|
label: 'Sub Job'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'gcodeFile',
|
|
||||||
label: 'GCode File'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'queue',
|
key: 'queue',
|
||||||
label: 'Queue'
|
label: 'Queue'
|
||||||
@ -386,9 +361,7 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
<Flex vertical gap={'large'}>
|
<Flex vertical gap={'large'}>
|
||||||
<ActionHandler
|
<ActionHandler
|
||||||
actions={actions}
|
actions={actions}
|
||||||
loading={
|
loading={objectFormState.loading}
|
||||||
objectFormState.loading || !objectFormState.objectData?._id
|
|
||||||
}
|
|
||||||
ref={actionHandlerRef}
|
ref={actionHandlerRef}
|
||||||
>
|
>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
@ -419,7 +392,6 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ObjectInfo
|
<ObjectInfo
|
||||||
{...objectInfoHyperlinkProps}
|
|
||||||
loading={printerObjectLoading}
|
loading={printerObjectLoading}
|
||||||
column={sideBarVisible ? 1 : undefined}
|
column={sideBarVisible ? 1 : undefined}
|
||||||
visibleProperties={{
|
visibleProperties={{
|
||||||
@ -450,6 +422,9 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
pendingSlicerUploads: false,
|
pendingSlicerUploads: false,
|
||||||
name: !slicerIntegration
|
name: !slicerIntegration
|
||||||
}}
|
}}
|
||||||
|
objectPropertyProps={{
|
||||||
|
showHyperlink: !slicerIntegration
|
||||||
|
}}
|
||||||
objectData={printerObjectData}
|
objectData={printerObjectData}
|
||||||
type='printer'
|
type='printer'
|
||||||
labelWidth='120px'
|
labelWidth='120px'
|
||||||
@ -478,8 +453,6 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
currentSubJob={
|
currentSubJob={
|
||||||
objectFormState.objectData?.currentSubJob
|
objectFormState.objectData?.currentSubJob
|
||||||
}
|
}
|
||||||
printerId={printerId}
|
|
||||||
showHyperlink={!slicerIntegration}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</InfoCollapse>
|
</InfoCollapse>
|
||||||
@ -492,9 +465,9 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
updateCollapseState('job', expanded)
|
updateCollapseState('job', expanded)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{currentJobId ? (
|
{objectFormState.objectData?.currentJob?._id ? (
|
||||||
<ObjectForm
|
<ObjectForm
|
||||||
id={currentJobId}
|
id={objectFormState.objectData.currentJob._id}
|
||||||
type='job'
|
type='job'
|
||||||
onStateChange={() => {}}
|
onStateChange={() => {}}
|
||||||
>
|
>
|
||||||
@ -504,14 +477,17 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ObjectInfo
|
<ObjectInfo
|
||||||
{...objectInfoHyperlinkProps}
|
|
||||||
loading={jobObjectLoading}
|
loading={jobObjectLoading}
|
||||||
column={sideBarVisible ? 1 : undefined}
|
column={sideBarVisible ? 1 : undefined}
|
||||||
|
showHyperlink={!slicerIntegration}
|
||||||
visibleProperties={{
|
visibleProperties={{
|
||||||
printers: false,
|
printers: false,
|
||||||
createdAt: false,
|
createdAt: false,
|
||||||
finishedAt: false
|
finishedAt: false
|
||||||
}}
|
}}
|
||||||
|
objectPropertyProps={{
|
||||||
|
showHyperlink: !slicerIntegration
|
||||||
|
}}
|
||||||
objectData={jobObjectData}
|
objectData={jobObjectData}
|
||||||
type='job'
|
type='job'
|
||||||
/>
|
/>
|
||||||
@ -531,14 +507,14 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
title={'Sub Job'}
|
title={'Sub Job'}
|
||||||
icon={<SubJobIcon />}
|
icon={<SubJobIcon />}
|
||||||
collapseKey='subJob'
|
collapseKey='subJob'
|
||||||
active={collapseState.subJob ?? collapseState.subjob}
|
active={collapseState.subJob}
|
||||||
onToggle={(expanded) =>
|
onToggle={(expanded) =>
|
||||||
updateCollapseState('subJob', expanded)
|
updateCollapseState('subJob', expanded)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{currentSubJobId ? (
|
{objectFormState.objectData?.currentSubJob?._id ? (
|
||||||
<ObjectForm
|
<ObjectForm
|
||||||
id={currentSubJobId}
|
id={objectFormState.objectData.currentSubJob._id}
|
||||||
type='subJob'
|
type='subJob'
|
||||||
onStateChange={() => {}}
|
onStateChange={() => {}}
|
||||||
>
|
>
|
||||||
@ -548,9 +524,9 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ObjectInfo
|
<ObjectInfo
|
||||||
{...objectInfoHyperlinkProps}
|
|
||||||
loading={subJobObjectLoading}
|
loading={subJobObjectLoading}
|
||||||
column={sideBarVisible ? 1 : undefined}
|
column={sideBarVisible ? 1 : undefined}
|
||||||
|
showHyperlink={!slicerIntegration}
|
||||||
visibleProperties={{
|
visibleProperties={{
|
||||||
printers: false,
|
printers: false,
|
||||||
createdAt: false,
|
createdAt: false,
|
||||||
@ -559,6 +535,9 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
job: false,
|
job: false,
|
||||||
printer: false
|
printer: false
|
||||||
}}
|
}}
|
||||||
|
objectPropertyProps={{
|
||||||
|
showHyperlink: !slicerIntegration
|
||||||
|
}}
|
||||||
objectData={subJobObjectData}
|
objectData={subJobObjectData}
|
||||||
type='subJob'
|
type='subJob'
|
||||||
/>
|
/>
|
||||||
@ -574,21 +553,6 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</InfoCollapse>
|
</InfoCollapse>
|
||||||
<InfoCollapse
|
|
||||||
title={'GCode File'}
|
|
||||||
icon={<GCodeFileIcon />}
|
|
||||||
collapseKey='gcodeFile'
|
|
||||||
active={collapseState.gcodeFile}
|
|
||||||
onToggle={(expanded) =>
|
|
||||||
updateCollapseState('gcodeFile', expanded)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PrinterGCodeFilePanel
|
|
||||||
id={printerId}
|
|
||||||
subJobId={currentSubJobId}
|
|
||||||
offline={!objectFormState.objectData?.online}
|
|
||||||
/>
|
|
||||||
</InfoCollapse>
|
|
||||||
<InfoCollapse
|
<InfoCollapse
|
||||||
title={'Filament Stock'}
|
title={'Filament Stock'}
|
||||||
icon={<FilamentStockIcon />}
|
icon={<FilamentStockIcon />}
|
||||||
@ -598,9 +562,13 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
updateCollapseState('filamentStock', expanded)
|
updateCollapseState('filamentStock', expanded)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{currentFilamentStockId ? (
|
{objectFormState.objectData?.currentFilamentStock
|
||||||
|
?._id ? (
|
||||||
<ObjectForm
|
<ObjectForm
|
||||||
id={currentFilamentStockId}
|
id={
|
||||||
|
objectFormState.objectData.currentFilamentStock
|
||||||
|
._id
|
||||||
|
}
|
||||||
type='filamentStock'
|
type='filamentStock'
|
||||||
onStateChange={() => {}}
|
onStateChange={() => {}}
|
||||||
>
|
>
|
||||||
@ -610,9 +578,9 @@ const ControlPrinter = ({ slicerIntegration = false }) => {
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ObjectInfo
|
<ObjectInfo
|
||||||
{...objectInfoHyperlinkProps}
|
|
||||||
loading={filamentStockObjectLoading}
|
loading={filamentStockObjectLoading}
|
||||||
column={sideBarVisible ? 1 : undefined}
|
column={sideBarVisible ? 1 : undefined}
|
||||||
|
showHyperlink={!slicerIntegration}
|
||||||
visibleProperties={{
|
visibleProperties={{
|
||||||
updatedAt: false,
|
updatedAt: false,
|
||||||
createdAt: false
|
createdAt: false
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useContext, useEffect, useRef, useState } from 'react'
|
import { useContext, useEffect, useState } from 'react'
|
||||||
import { Divider, Flex, Layout, Typography, Button, Badge, Modal } from 'antd'
|
import { Divider, Flex, Layout, Typography, Button, Badge, Modal } from 'antd'
|
||||||
import { LoadingOutlined } from '@ant-design/icons'
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
@ -18,9 +18,7 @@ const { Title } = Typography
|
|||||||
const SlicerIntegration = () => {
|
const SlicerIntegration = () => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const printerId = new URLSearchParams(location.search).get('printerId')
|
const printerId = new URLSearchParams(location.search).get('printerId')
|
||||||
const { connected, updateObject, subscribeToObjectUpdates } =
|
const { updateObject } = useContext(ApiServerContext)
|
||||||
useContext(ApiServerContext)
|
|
||||||
const receivedPendingUploadsUpdateRef = useRef(false)
|
|
||||||
|
|
||||||
const [pendingSlicerUploads, setPendingSlicerUploads] = useState([])
|
const [pendingSlicerUploads, setPendingSlicerUploads] = useState([])
|
||||||
const [currentPendingSlicerUpload, setCurrentPendingSlicerUpload] =
|
const [currentPendingSlicerUpload, setCurrentPendingSlicerUpload] =
|
||||||
@ -45,44 +43,11 @@ const SlicerIntegration = () => {
|
|||||||
const [showSlicerUploadFlow, setShowSlicerUploadFlow] = useState(false)
|
const [showSlicerUploadFlow, setShowSlicerUploadFlow] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (receivedPendingUploadsUpdateRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setPendingSlicerUploads(
|
setPendingSlicerUploads(
|
||||||
objectFormState.objectData?.pendingSlicerUploads || []
|
objectFormState.objectData?.pendingSlicerUploads || []
|
||||||
)
|
)
|
||||||
}, [objectFormState.objectData?.pendingSlicerUploads])
|
}, [objectFormState.objectData?.pendingSlicerUploads])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!printerId || connected !== true) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribe = subscribeToObjectUpdates(
|
|
||||||
String(printerId).toLowerCase(),
|
|
||||||
'printer',
|
|
||||||
(object) => {
|
|
||||||
if (
|
|
||||||
!object ||
|
|
||||||
!Object.prototype.hasOwnProperty.call(object, 'pendingSlicerUploads')
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
receivedPendingUploadsUpdateRef.current = true
|
|
||||||
setPendingSlicerUploads(
|
|
||||||
Array.isArray(object.pendingSlicerUploads)
|
|
||||||
? object.pendingSlicerUploads
|
|
||||||
: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (unsubscribe) unsubscribe()
|
|
||||||
}
|
|
||||||
}, [connected, printerId, subscribeToObjectUpdates])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextUpload = pendingSlicerUploads.find(
|
const nextUpload = pendingSlicerUploads.find(
|
||||||
(upload) => upload.new === true
|
(upload) => upload.new === true
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
import { useState, useContext } from 'react'
|
|
||||||
import PropTypes from 'prop-types'
|
|
||||||
import { ApiServerContext } from '../../context/ApiServerContext'
|
|
||||||
import { message } from 'antd'
|
|
||||||
import MessageDialogView from '../../common/MessageDialogView.jsx'
|
|
||||||
|
|
||||||
const getRefId = (value) => {
|
|
||||||
if (!value) return null
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
if (typeof value._id === 'string') return value._id
|
|
||||||
if (typeof value._id === 'object' && value._id?._id) return value._id._id
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const CancelSubJob = ({ onOk, objectData }) => {
|
|
||||||
const [cancelLoading, setCancelLoading] = useState(false)
|
|
||||||
const { connected, sendObjectAction } = useContext(ApiServerContext)
|
|
||||||
|
|
||||||
const handleCancel = async () => {
|
|
||||||
if (!connected) {
|
|
||||||
message.error('Not connected to server')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const printerId = getRefId(objectData?.printer)
|
|
||||||
const subJobId = getRefId(objectData)
|
|
||||||
if (!printerId || !subJobId) {
|
|
||||||
message.error('Sub job is not assigned to a printer')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCancelLoading(true)
|
|
||||||
try {
|
|
||||||
sendObjectAction(printerId, 'printer', {
|
|
||||||
type: 'cancelSubJob',
|
|
||||||
data: {
|
|
||||||
_id: subJobId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
message.success('Sub job cancel initiated')
|
|
||||||
onOk()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error cancelling sub job:', error)
|
|
||||||
} finally {
|
|
||||||
setCancelLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const subJobLabel =
|
|
||||||
objectData?.name || objectData?._reference || objectData?._id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MessageDialogView
|
|
||||||
title='Are you sure you want to cancel this sub job?'
|
|
||||||
description={`Cancelling sub job ${subJobLabel} will remove it from the printer queue.`}
|
|
||||||
onOk={handleCancel}
|
|
||||||
okText='Cancel Sub Job'
|
|
||||||
okLoading={cancelLoading}
|
|
||||||
danger
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
CancelSubJob.propTypes = {
|
|
||||||
onOk: PropTypes.func.isRequired,
|
|
||||||
objectData: PropTypes.object
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CancelSubJob
|
|
||||||
@ -17,8 +17,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Divider,
|
Divider,
|
||||||
Typography,
|
Typography,
|
||||||
Popover,
|
Popover
|
||||||
theme
|
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
@ -47,7 +46,6 @@ import SettingsIcon from '../../Icons/SettingsIcon'
|
|||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import DashboardWindowButtons from './DashboardWindowButtons'
|
import DashboardWindowButtons from './DashboardWindowButtons'
|
||||||
import DashboardDevControls from './DashboardDevControls'
|
import DashboardDevControls from './DashboardDevControls'
|
||||||
import LiveActivities from './LiveActivities'
|
|
||||||
import WindowAppMenu from './WindowAppMenu'
|
import WindowAppMenu from './WindowAppMenu'
|
||||||
import WebAppSwitcher from './WebAppSwitcher'
|
import WebAppSwitcher from './WebAppSwitcher'
|
||||||
import DashboardTabs from './DashboardTabs'
|
import DashboardTabs from './DashboardTabs'
|
||||||
@ -150,8 +148,6 @@ const DashboardNavigationTrailingItems = memo(
|
|||||||
onNotifications,
|
onNotifications,
|
||||||
onCheckUpdates
|
onCheckUpdates
|
||||||
}) {
|
}) {
|
||||||
const { token } = theme.useToken()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex gap='small' align='center' style={NAV_TRAILING_FLEX_STYLE}>
|
<Flex gap='small' align='center' style={NAV_TRAILING_FLEX_STYLE}>
|
||||||
{controlsReady ? (
|
{controlsReady ? (
|
||||||
@ -169,34 +165,25 @@ const DashboardNavigationTrailingItems = memo(
|
|||||||
onClick={onSearch}
|
onClick={onSearch}
|
||||||
/>
|
/>
|
||||||
</KeyboardShortcut>
|
</KeyboardShortcut>
|
||||||
<Flex
|
<Badge
|
||||||
align='center'
|
count={unreadCount}
|
||||||
className='dashboard-navigation-activity-cluster'
|
size='small'
|
||||||
style={{
|
offset={[-5, 8]}
|
||||||
'--dashboard-navigation-gap': `${token.paddingXS}px`
|
style={NAV_BADGE_STYLE}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LiveActivities />
|
<KeyboardShortcut
|
||||||
<Badge
|
shortcut='alt+n'
|
||||||
count={unreadCount}
|
hint='ALT N'
|
||||||
size='small'
|
onTrigger={onNotifications}
|
||||||
offset={[-5, 8]}
|
|
||||||
style={NAV_BADGE_STYLE}
|
|
||||||
>
|
>
|
||||||
<KeyboardShortcut
|
<Button
|
||||||
shortcut='alt+n'
|
icon={<BellIcon />}
|
||||||
hint='ALT N'
|
type='text'
|
||||||
onTrigger={onNotifications}
|
style={NAV_BELL_BUTTON_STYLE}
|
||||||
>
|
onClick={onNotifications}
|
||||||
<Button
|
/>
|
||||||
icon={<BellIcon />}
|
</KeyboardShortcut>
|
||||||
type='text'
|
</Badge>
|
||||||
style={NAV_BELL_BUTTON_STYLE}
|
|
||||||
onClick={onNotifications}
|
|
||||||
/>
|
|
||||||
</KeyboardShortcut>
|
|
||||||
</Badge>
|
|
||||||
</Flex>
|
|
||||||
</Space>
|
</Space>
|
||||||
) : null}
|
) : null}
|
||||||
<DashboardDevControls />
|
<DashboardDevControls />
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
memo,
|
memo,
|
||||||
useRef
|
useRef
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import GCodePreview from './GCodeViewer'
|
import GCodePreview from './GCodePreview'
|
||||||
import ThreeDPreview from './ThreeDPreview'
|
import ThreeDPreview from './ThreeDPreview'
|
||||||
import PDFPreview from './PDFPreview'
|
import PDFPreview from './PDFPreview'
|
||||||
import ProgressDisplay from './ProgressDisplay'
|
import ProgressDisplay from './ProgressDisplay'
|
||||||
@ -111,7 +111,13 @@ const FilePreview = ({ file, style = {} }) => {
|
|||||||
|
|
||||||
if (isGcode && fileObjectUrl) {
|
if (isGcode && fileObjectUrl) {
|
||||||
return (
|
return (
|
||||||
<GCodePreview src={fileObjectUrl} style={style} />
|
<GCodePreview
|
||||||
|
src={fileObjectUrl}
|
||||||
|
startLayer={0}
|
||||||
|
endLayer={undefined}
|
||||||
|
lineWidth={1}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
438
src/components/Dashboard/common/GCodePreview.jsx
Normal file
438
src/components/Dashboard/common/GCodePreview.jsx
Normal file
@ -0,0 +1,438 @@
|
|||||||
|
import { GCodePreview } from 'gcode-preview'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { Button, Card, Flex, Select, Slider } from 'antd'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import PlusIcon from '../../Icons/PlusIcon.jsx'
|
||||||
|
import MinusIcon from '../../Icons/MinusIcon.jsx'
|
||||||
|
import PanIcon from '../../Icons/PanIcon.jsx'
|
||||||
|
import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut.jsx'
|
||||||
|
import usePinchZoom, {
|
||||||
|
clampPreviewScale,
|
||||||
|
MAX_PREVIEW_SCALE,
|
||||||
|
MIN_PREVIEW_SCALE
|
||||||
|
} from '../hooks/usePinchZoom.js'
|
||||||
|
import { useThemeContext } from '../context/ThemeContext'
|
||||||
|
|
||||||
|
function toCssColor(color, fallback) {
|
||||||
|
try {
|
||||||
|
return `#${new THREE.Color(color || fallback).getHexString()}`
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewThemeColors({
|
||||||
|
isDarkMode,
|
||||||
|
token,
|
||||||
|
topLayerColor,
|
||||||
|
lastSegmentColor
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||||
|
extrusionColor: token.colorPrimary,
|
||||||
|
travelColor: isDarkMode ? '#8c8c8c' : '#bfbfbf',
|
||||||
|
topLayerColor: topLayerColor || token.colorWarning,
|
||||||
|
lastSegmentColor: lastSegmentColor || token.colorPink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraDistance(sceneManager) {
|
||||||
|
const distance = sceneManager.camera.position.distanceTo(
|
||||||
|
sceneManager.controls.target
|
||||||
|
)
|
||||||
|
return Number.isFinite(distance) && distance > 0 ? distance : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraScale(sceneManager, baseDistance) {
|
||||||
|
const distance = getCameraDistance(sceneManager)
|
||||||
|
return distance == null ? null : clampPreviewScale(baseDistance / distance)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCameraScale(sceneManager, baseDistance, scale) {
|
||||||
|
const { camera, controls } = sceneManager
|
||||||
|
const distance = getCameraDistance(sceneManager)
|
||||||
|
if (distance == null) return
|
||||||
|
|
||||||
|
const nextDistance = baseDistance / scale
|
||||||
|
if (Math.abs(nextDistance - distance) <= distance * 1e-4) return
|
||||||
|
|
||||||
|
const offset = camera.position.clone().sub(controls.target)
|
||||||
|
camera.position
|
||||||
|
.copy(controls.target)
|
||||||
|
.addScaledVector(offset, nextDistance / distance)
|
||||||
|
controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
function GCodePreviewUI(props) {
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
topLayerColor = '',
|
||||||
|
lastSegmentColor = '',
|
||||||
|
startLayer,
|
||||||
|
endLayer,
|
||||||
|
lineWidth,
|
||||||
|
style = {}
|
||||||
|
} = props
|
||||||
|
const { isDarkMode, themeConfig } = useThemeContext()
|
||||||
|
const { colorPrimary, colorWarning, colorPink } = themeConfig.token
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const previewPaneRef = useRef(null)
|
||||||
|
const previewRef = useRef(null)
|
||||||
|
const baseDistanceRef = useRef(1)
|
||||||
|
const applyingScaleRef = useRef(false)
|
||||||
|
const [preview, setPreview] = useState(null)
|
||||||
|
const [previewScale, setPreviewScale] = useState(1)
|
||||||
|
const [panMode, setPanMode] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [renderMode, setRenderMode] = useState('lines')
|
||||||
|
const [travelVisibility, setTravelVisibility] = useState('hide')
|
||||||
|
const renderModeRef = useRef(renderMode)
|
||||||
|
const travelVisibilityRef = useRef(travelVisibility)
|
||||||
|
renderModeRef.current = renderMode
|
||||||
|
travelVisibilityRef.current = travelVisibility
|
||||||
|
|
||||||
|
const resizePreview = useCallback(() => {
|
||||||
|
previewRef.current?.sceneManager?.resize()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return
|
||||||
|
|
||||||
|
const themeColors = getPreviewThemeColors({
|
||||||
|
isDarkMode,
|
||||||
|
token: { colorPrimary, colorWarning, colorPink },
|
||||||
|
topLayerColor,
|
||||||
|
lastSegmentColor
|
||||||
|
})
|
||||||
|
|
||||||
|
previewRef.current?.dispose()
|
||||||
|
const instance = new GCodePreview({
|
||||||
|
canvas: canvasRef.current,
|
||||||
|
startLayer,
|
||||||
|
endLayer,
|
||||||
|
lineWidth,
|
||||||
|
backgroundColor: toCssColor(themeColors.backgroundColor, '#ffffff'),
|
||||||
|
extrusionColor: toCssColor(themeColors.extrusionColor, '#0091ff'),
|
||||||
|
travelColor: toCssColor(themeColors.travelColor, '#8c8c8c'),
|
||||||
|
topLayerColor: toCssColor(themeColors.topLayerColor, '#ff9230'),
|
||||||
|
lastSegmentColor: toCssColor(themeColors.lastSegmentColor, '#ff69b4'),
|
||||||
|
buildVolume: { x: 250, y: 220, z: 150 },
|
||||||
|
initialCameraPosition: [0, 400, 450],
|
||||||
|
droppable: false,
|
||||||
|
renderTubes: renderModeRef.current === 'pipes',
|
||||||
|
renderTravel: travelVisibilityRef.current === 'show'
|
||||||
|
})
|
||||||
|
previewRef.current = instance
|
||||||
|
|
||||||
|
const { camera, controls } = instance.sceneManager
|
||||||
|
baseDistanceRef.current = getCameraDistance(instance.sceneManager) || 1
|
||||||
|
// The camera dollies instead of scaling pixels, so the frustum has to
|
||||||
|
// cover the full zoom range.
|
||||||
|
camera.far = Math.max(
|
||||||
|
camera.far,
|
||||||
|
(baseDistanceRef.current / MIN_PREVIEW_SCALE) * 2
|
||||||
|
)
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
controls.minDistance = baseDistanceRef.current / MAX_PREVIEW_SCALE
|
||||||
|
controls.maxDistance = baseDistanceRef.current / MIN_PREVIEW_SCALE
|
||||||
|
|
||||||
|
const handleControlsChange = () => {
|
||||||
|
if (applyingScaleRef.current) return
|
||||||
|
const scale = getCameraScale(
|
||||||
|
instance.sceneManager,
|
||||||
|
baseDistanceRef.current
|
||||||
|
)
|
||||||
|
if (scale == null) return
|
||||||
|
setPreviewScale((prev) => (Math.abs(prev - scale) < 0.001 ? prev : scale))
|
||||||
|
}
|
||||||
|
const handleControlsStart = () => setIsDragging(true)
|
||||||
|
const handleControlsEnd = () => setIsDragging(false)
|
||||||
|
|
||||||
|
controls.addEventListener('change', handleControlsChange)
|
||||||
|
controls.addEventListener('start', handleControlsStart)
|
||||||
|
controls.addEventListener('end', handleControlsEnd)
|
||||||
|
|
||||||
|
instance.sceneManager.resize()
|
||||||
|
setPreview(instance)
|
||||||
|
|
||||||
|
const pane = previewPaneRef.current
|
||||||
|
const resizeObserver =
|
||||||
|
pane && typeof ResizeObserver === 'function'
|
||||||
|
? new ResizeObserver(resizePreview)
|
||||||
|
: null
|
||||||
|
resizeObserver?.observe(pane)
|
||||||
|
window.addEventListener('resize', resizePreview)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
window.removeEventListener('resize', resizePreview)
|
||||||
|
controls.removeEventListener('change', handleControlsChange)
|
||||||
|
controls.removeEventListener('start', handleControlsStart)
|
||||||
|
controls.removeEventListener('end', handleControlsEnd)
|
||||||
|
setPreview(null)
|
||||||
|
setIsDragging(false)
|
||||||
|
instance.clear()
|
||||||
|
instance.dispose()
|
||||||
|
if (previewRef.current === instance) {
|
||||||
|
previewRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
colorPink,
|
||||||
|
colorPrimary,
|
||||||
|
colorWarning,
|
||||||
|
endLayer,
|
||||||
|
isDarkMode,
|
||||||
|
lastSegmentColor,
|
||||||
|
lineWidth,
|
||||||
|
startLayer,
|
||||||
|
topLayerColor,
|
||||||
|
resizePreview
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview) return
|
||||||
|
applyingScaleRef.current = true
|
||||||
|
applyCameraScale(
|
||||||
|
preview.sceneManager,
|
||||||
|
baseDistanceRef.current,
|
||||||
|
previewScale
|
||||||
|
)
|
||||||
|
applyingScaleRef.current = false
|
||||||
|
}, [preview, previewScale])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview) return
|
||||||
|
const { sceneManager } = preview
|
||||||
|
const renderTubes = renderMode === 'pipes'
|
||||||
|
const renderTravel = travelVisibility === 'show'
|
||||||
|
if (
|
||||||
|
sceneManager.renderTubes === renderTubes &&
|
||||||
|
sceneManager.renderTravel === renderTravel
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sceneManager.renderTubes = renderTubes
|
||||||
|
sceneManager.renderTravel = renderTravel
|
||||||
|
sceneManager.render()
|
||||||
|
}, [preview, renderMode, travelVisibility])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview) return
|
||||||
|
const { controls } = preview.sceneManager
|
||||||
|
controls.mouseButtons = {
|
||||||
|
LEFT: panMode ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE,
|
||||||
|
MIDDLE: THREE.MOUSE.DOLLY,
|
||||||
|
RIGHT: panMode ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN
|
||||||
|
}
|
||||||
|
controls.touches = {
|
||||||
|
ONE: panMode ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE,
|
||||||
|
TWO: THREE.TOUCH.DOLLY_PAN
|
||||||
|
}
|
||||||
|
}, [preview, panMode])
|
||||||
|
|
||||||
|
usePinchZoom({
|
||||||
|
containerRef: previewPaneRef,
|
||||||
|
scale: previewScale,
|
||||||
|
onScaleChange: setPreviewScale,
|
||||||
|
enabled: preview != null
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
preview?.sceneManager.controls.reset()
|
||||||
|
setPreviewScale(1)
|
||||||
|
}, [preview])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
const loadFromSrc = async () => {
|
||||||
|
const instance = previewRef.current
|
||||||
|
if (!src || !instance) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(src, { signal: abortController.signal })
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Failed to load G-code (${response.status})`)
|
||||||
|
}
|
||||||
|
if (abortController.signal.aborted || previewRef.current !== instance) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (instance.countLayers > 0) {
|
||||||
|
instance.clear()
|
||||||
|
instance.sceneManager.startLayer = startLayer
|
||||||
|
instance.sceneManager.endLayer = endLayer
|
||||||
|
}
|
||||||
|
const stream = response.body.pipeThrough(new TextDecoderStream())
|
||||||
|
await instance.processGCodeStream(stream)
|
||||||
|
if (abortController.signal.aborted || previewRef.current !== instance) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof startLayer === 'number') {
|
||||||
|
instance.sceneManager.startLayer = startLayer
|
||||||
|
}
|
||||||
|
if (typeof endLayer === 'number') {
|
||||||
|
instance.sceneManager.endLayer = endLayer
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (abortController.signal.aborted) return
|
||||||
|
console.error('Failed to load G-code from src', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadFromSrc()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
colorPink,
|
||||||
|
colorPrimary,
|
||||||
|
colorWarning,
|
||||||
|
endLayer,
|
||||||
|
isDarkMode,
|
||||||
|
lastSegmentColor,
|
||||||
|
lineWidth,
|
||||||
|
src,
|
||||||
|
startLayer,
|
||||||
|
topLayerColor
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap={'middle'}
|
||||||
|
style={{ width: '100%', height: '100%', minHeight: 0, ...style }}
|
||||||
|
>
|
||||||
|
<Flex gap={'small'} wrap='wrap' align='center'>
|
||||||
|
<KeyboardShortcut
|
||||||
|
shortcut={'alt+p'}
|
||||||
|
hint={'ALT P'}
|
||||||
|
onTrigger={() => {
|
||||||
|
setPanMode((prev) => !prev)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
panMode ? (
|
||||||
|
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
|
||||||
|
) : (
|
||||||
|
<PanIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setPanMode((prev) => !prev)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</KeyboardShortcut>
|
||||||
|
<Button
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewScale((prev) => clampPreviewScale(prev + 0.05))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
readOnly={true}
|
||||||
|
style={{ minWidth: '70px' }}
|
||||||
|
onClick={resetView}
|
||||||
|
>
|
||||||
|
{previewScale.toFixed(2)}x
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<MinusIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewScale((prev) => clampPreviewScale(prev - 0.05))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={renderMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'lines', label: 'Lines' },
|
||||||
|
{ value: 'pipes', label: 'Pipes' }
|
||||||
|
]}
|
||||||
|
style={{ minWidth: 77 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
onChange={setRenderMode}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={travelVisibility}
|
||||||
|
options={[
|
||||||
|
{ value: 'show', label: 'Show Travel' },
|
||||||
|
{ value: 'hide', label: 'Hide Travel' }
|
||||||
|
]}
|
||||||
|
style={{ minWidth: 118 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
onChange={setTravelVisibility}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={previewPaneRef}
|
||||||
|
style={{ flex: '1 1 auto', minHeight: 0, position: 'relative' }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 0
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 0
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'var(--layout-modal-bg)',
|
||||||
|
cursor: panMode ? (isDragging ? 'grabbing' : 'grab') : undefined
|
||||||
|
}}
|
||||||
|
></canvas>
|
||||||
|
</Card>
|
||||||
|
<div className='previewZoomOverlay'>
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 18 }}
|
||||||
|
styles={{ body: { padding: '10px 17px', width: 200 } }}
|
||||||
|
className='previewZoomCard'
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={MIN_PREVIEW_SCALE}
|
||||||
|
max={MAX_PREVIEW_SCALE}
|
||||||
|
step={0.05}
|
||||||
|
tooltip={{ open: false }}
|
||||||
|
value={previewScale}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
onChange={(value) => setPreviewScale(value)}
|
||||||
|
classNames={{
|
||||||
|
handle: 'previewZoomSliderHandle'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
track: {
|
||||||
|
background: 'var(--color-primary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
GCodePreviewUI.propTypes = {
|
||||||
|
src: PropTypes.string,
|
||||||
|
topLayerColor: PropTypes.string,
|
||||||
|
lastSegmentColor: PropTypes.string,
|
||||||
|
startLayer: PropTypes.number,
|
||||||
|
endLayer: PropTypes.number,
|
||||||
|
lineWidth: PropTypes.number,
|
||||||
|
style: PropTypes.object
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GCodePreviewUI
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from '../../Components/GCodePreview/GCodePreview.jsx'
|
|
||||||
@ -1,657 +0,0 @@
|
|||||||
import {
|
|
||||||
createElement,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState
|
|
||||||
} from 'react'
|
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
import { snapdom } from '@zumer/snapdom'
|
|
||||||
import { theme, Popover } from 'antd'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
|
||||||
import { AuthContext } from '../context/AuthContext'
|
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
|
||||||
import { getModelByName } from '../../../database/ObjectModels'
|
|
||||||
import { getStateTagInfo } from '../utils/Utils'
|
|
||||||
import SpotlightTooltip from './SpotlightTooltip'
|
|
||||||
|
|
||||||
const ACTIVITY_TYPES = new Set([
|
|
||||||
'printing',
|
|
||||||
'processing',
|
|
||||||
'deploying',
|
|
||||||
'loading',
|
|
||||||
'queued',
|
|
||||||
'syncing',
|
|
||||||
'publishing',
|
|
||||||
'unpublishing',
|
|
||||||
'initializing',
|
|
||||||
'rendering',
|
|
||||||
'sending',
|
|
||||||
'connecting',
|
|
||||||
'downloading',
|
|
||||||
'startup',
|
|
||||||
'paused',
|
|
||||||
'used'
|
|
||||||
])
|
|
||||||
|
|
||||||
const PROGRESS_TYPES = new Set([
|
|
||||||
'loading',
|
|
||||||
'processing',
|
|
||||||
'queued',
|
|
||||||
'printing',
|
|
||||||
'used',
|
|
||||||
'deploying',
|
|
||||||
'syncing',
|
|
||||||
'publishing',
|
|
||||||
'unpublishing'
|
|
||||||
])
|
|
||||||
|
|
||||||
const STATUS_COLOR_VAR = {
|
|
||||||
success: '--color-success',
|
|
||||||
warning: '--color-warning',
|
|
||||||
error: '--color-error',
|
|
||||||
processing: '--color-info',
|
|
||||||
info: '--color-info',
|
|
||||||
default: '--color-info',
|
|
||||||
cyan: '--color-cyan',
|
|
||||||
purple: '--color-purple',
|
|
||||||
magenta: '--color-magenta',
|
|
||||||
volcano: '--color-volcano',
|
|
||||||
pink: '--color-pink'
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIVE_ACTIVITY_TRANSITION_MS = 300
|
|
||||||
const FAVICON_PX = 32
|
|
||||||
|
|
||||||
let faviconOriginalHref
|
|
||||||
let faviconOriginalType
|
|
||||||
let faviconAppliedHref
|
|
||||||
let faviconObjectUrl
|
|
||||||
let faviconCaptureChain = Promise.resolve()
|
|
||||||
|
|
||||||
const faviconLink = () => document.querySelector('link[rel="icon"]')
|
|
||||||
|
|
||||||
const tightenFaviconSvg = (src) => {
|
|
||||||
const comma = src.indexOf(',')
|
|
||||||
if (comma < 0 || !src.slice(0, comma).includes('svg')) return src
|
|
||||||
let svg = decodeURIComponent(src.slice(comma + 1))
|
|
||||||
svg = svg.replace(/viewBox="0 0 ([\d.]+) ([\d.]+)"/, (match, w, h) => {
|
|
||||||
const width = Number(w)
|
|
||||||
const height = Number(h)
|
|
||||||
if (!Number.isFinite(width) || width < 4 || width !== height) return match
|
|
||||||
return `viewBox="1 1 ${width - 2} ${height - 2}"`
|
|
||||||
})
|
|
||||||
return `${src.slice(0, comma)},${encodeURIComponent(svg)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyLiveFavicon = (href) => {
|
|
||||||
const tightened = href ? tightenFaviconSvg(href) : href
|
|
||||||
if (!tightened || tightened === faviconAppliedHref) return
|
|
||||||
const current = faviconLink()
|
|
||||||
if (!current) return
|
|
||||||
if (faviconOriginalHref === undefined) {
|
|
||||||
faviconOriginalHref = current.getAttribute('href')
|
|
||||||
faviconOriginalType = current.getAttribute('type')
|
|
||||||
}
|
|
||||||
const next = document.createElement('link')
|
|
||||||
next.rel = 'icon'
|
|
||||||
next.type = 'image/svg+xml'
|
|
||||||
next.href = tightened
|
|
||||||
current.replaceWith(next)
|
|
||||||
if (faviconObjectUrl && faviconObjectUrl !== tightened) {
|
|
||||||
URL.revokeObjectURL(faviconObjectUrl)
|
|
||||||
}
|
|
||||||
faviconObjectUrl = tightened.startsWith('blob:') ? tightened : null
|
|
||||||
faviconAppliedHref = tightened
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreLiveFavicon = () => {
|
|
||||||
if (faviconAppliedHref == null) return
|
|
||||||
const current = faviconLink()
|
|
||||||
faviconAppliedHref = null
|
|
||||||
if (faviconObjectUrl) {
|
|
||||||
URL.revokeObjectURL(faviconObjectUrl)
|
|
||||||
faviconObjectUrl = null
|
|
||||||
}
|
|
||||||
if (!current || faviconOriginalHref == null) return
|
|
||||||
const next = document.createElement('link')
|
|
||||||
next.rel = 'icon'
|
|
||||||
if (faviconOriginalType) next.type = faviconOriginalType
|
|
||||||
next.href = faviconOriginalHref
|
|
||||||
current.replaceWith(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitForPaint = () =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
window.requestAnimationFrame(resolve)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const renderLiveActivityFace = ({
|
|
||||||
activity,
|
|
||||||
size,
|
|
||||||
iconSize,
|
|
||||||
label,
|
|
||||||
tabIndex = 0,
|
|
||||||
onClick
|
|
||||||
}) => {
|
|
||||||
const colorVar = STATUS_COLOR_VAR[activity.status] || '--color-info'
|
|
||||||
const color = `var(${colorVar})`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='live-activity'
|
|
||||||
aria-label={label}
|
|
||||||
aria-hidden={onClick ? undefined : true}
|
|
||||||
tabIndex={onClick ? tabIndex : -1}
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
'--live-activity-color': color
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
activity.percent == null
|
|
||||||
? 'live-activity-flash'
|
|
||||||
: 'live-activity-progress'
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
background: activity.percent == null ? color : undefined,
|
|
||||||
width: activity.percent == null ? undefined : `${activity.percent}%`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{activity.percent == null ? null : (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className='live-activity-progress-fill'
|
|
||||||
style={{ background: color }}
|
|
||||||
/>
|
|
||||||
<span className='live-activity-progress-wave' />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className='live-activity-icon'>
|
|
||||||
{createElement(activity.model.icon, {
|
|
||||||
style: { fontSize: iconSize }
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const readState = (object) => {
|
|
||||||
const raw = object?.state ?? object?.status
|
|
||||||
if (raw && typeof raw === 'object') {
|
|
||||||
return {
|
|
||||||
type: raw.type,
|
|
||||||
progress: raw.progress,
|
|
||||||
message: raw.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
return { type: raw }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasStateField = (object) => {
|
|
||||||
if (!object || typeof object !== 'object') return false
|
|
||||||
return object.state != null || object.status != null
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressPercent = (state) => {
|
|
||||||
if (!state || !PROGRESS_TYPES.has(state.type)) return null
|
|
||||||
const progress = Number(state.progress)
|
|
||||||
if (!Number.isFinite(progress) || progress <= 0) return null
|
|
||||||
const percent =
|
|
||||||
progress <= 1
|
|
||||||
? Math.round(progress * 1000) / 10
|
|
||||||
: Math.round(progress * 10) / 10
|
|
||||||
if (percent <= 0 || percent >= 100) return null
|
|
||||||
return percent
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectIdOf = (value) => {
|
|
||||||
if (!value) return null
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
return value._id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeObjectPatch = (current, patch) => {
|
|
||||||
if (!patch || typeof patch !== 'object') return current
|
|
||||||
const next = { ...(current || {}), ...patch }
|
|
||||||
if (
|
|
||||||
patch.state &&
|
|
||||||
typeof patch.state === 'object' &&
|
|
||||||
current?.state &&
|
|
||||||
typeof current.state === 'object'
|
|
||||||
) {
|
|
||||||
next.state = { ...current.state, ...patch.state }
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
patch.status &&
|
|
||||||
typeof patch.status === 'object' &&
|
|
||||||
current?.status &&
|
|
||||||
typeof current.status === 'object'
|
|
||||||
) {
|
|
||||||
next.status = { ...current.status, ...patch.status }
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiveActivities = () => {
|
|
||||||
const { token } = theme.useToken()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const {
|
|
||||||
userProfile,
|
|
||||||
token: authToken,
|
|
||||||
authInitialized
|
|
||||||
} = useContext(AuthContext)
|
|
||||||
const {
|
|
||||||
fetchObjects,
|
|
||||||
connected,
|
|
||||||
subscribeToObjectTypeUpdates,
|
|
||||||
subscribeToObjectUpdates
|
|
||||||
} = useContext(ApiServerContext)
|
|
||||||
const { isElectron, setAppProgress } = useContext(ElectronContext)
|
|
||||||
const [watched, setWatched] = useState([])
|
|
||||||
|
|
||||||
const userId = userProfile?._id
|
|
||||||
const authReady = Boolean(authToken && authInitialized && userId)
|
|
||||||
|
|
||||||
const apiRef = useRef({ fetchObjects })
|
|
||||||
apiRef.current = { fetchObjects }
|
|
||||||
const watchedRef = useRef(watched)
|
|
||||||
watchedRef.current = watched
|
|
||||||
|
|
||||||
const loadNotifiers = useCallback(async () => {
|
|
||||||
if (!userId) {
|
|
||||||
setWatched([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await apiRef.current.fetchObjects('userNotifier', {
|
|
||||||
filter: { user: userId },
|
|
||||||
limit: 200
|
|
||||||
})
|
|
||||||
const notifiers = Array.isArray(result?.data)
|
|
||||||
? result.data
|
|
||||||
: Array.isArray(result)
|
|
||||||
? result
|
|
||||||
: []
|
|
||||||
|
|
||||||
setWatched(
|
|
||||||
notifiers
|
|
||||||
.map((notifier) => {
|
|
||||||
const objectId = objectIdOf(notifier?.object)
|
|
||||||
if (!objectId || !notifier?.objectType) return null
|
|
||||||
const object =
|
|
||||||
notifier.object && typeof notifier.object === 'object'
|
|
||||||
? notifier.object
|
|
||||||
: { _id: objectId }
|
|
||||||
return {
|
|
||||||
notifierId: notifier._id,
|
|
||||||
objectType: notifier.objectType,
|
|
||||||
object
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
)
|
|
||||||
}, [userId])
|
|
||||||
|
|
||||||
const loadNotifiersRef = useRef(loadNotifiers)
|
|
||||||
loadNotifiersRef.current = loadNotifiers
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authReady || !connected) {
|
|
||||||
if (!authReady) setWatched([])
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
loadNotifiersRef.current()
|
|
||||||
return undefined
|
|
||||||
}, [authReady, connected])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authReady || connected !== true || !userId) return undefined
|
|
||||||
const unsubscribe = subscribeToObjectTypeUpdates(
|
|
||||||
'userNotifier',
|
|
||||||
{ user: userId },
|
|
||||||
() => {
|
|
||||||
loadNotifiersRef.current()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return () => unsubscribe?.()
|
|
||||||
}, [authReady, connected, subscribeToObjectTypeUpdates, userId])
|
|
||||||
|
|
||||||
const subscriptionKey = useMemo(
|
|
||||||
() =>
|
|
||||||
watched
|
|
||||||
.filter((item) => hasStateField(item.object))
|
|
||||||
.map(
|
|
||||||
(item) =>
|
|
||||||
`${item.objectType}:${String(item.object._id).toLowerCase()}`
|
|
||||||
)
|
|
||||||
.sort()
|
|
||||||
.join('|'),
|
|
||||||
[watched]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (connected !== true || !subscriptionKey) return undefined
|
|
||||||
|
|
||||||
const unsubscribes = watchedRef.current
|
|
||||||
.filter((item) => hasStateField(item.object))
|
|
||||||
.map((item) => {
|
|
||||||
const id = String(item.object._id).toLowerCase()
|
|
||||||
return subscribeToObjectUpdates(id, item.objectType, (patch) => {
|
|
||||||
setWatched((prev) =>
|
|
||||||
prev.map((entry) => {
|
|
||||||
const entryId = entry.object?._id
|
|
||||||
if (!entryId || String(entryId).toLowerCase() !== id) return entry
|
|
||||||
if (entry.objectType !== item.objectType) return entry
|
|
||||||
return {
|
|
||||||
...entry,
|
|
||||||
object: mergeObjectPatch(entry.object, patch)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribes.forEach((unsubscribe) => unsubscribe?.())
|
|
||||||
}
|
|
||||||
}, [connected, subscriptionKey, subscribeToObjectUpdates])
|
|
||||||
|
|
||||||
const activities = useMemo(
|
|
||||||
() =>
|
|
||||||
watched.flatMap((item) => {
|
|
||||||
const state = readState(item.object)
|
|
||||||
if (!state?.type || !ACTIVITY_TYPES.has(state.type)) return []
|
|
||||||
const model = getModelByName(item.objectType)
|
|
||||||
const { status, text } = getStateTagInfo(state.type)
|
|
||||||
const percent = progressPercent(state)
|
|
||||||
const name = item.object?.name || model.label
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: `${item.objectType}:${item.object._id}`,
|
|
||||||
id: item.object._id,
|
|
||||||
name,
|
|
||||||
model,
|
|
||||||
status,
|
|
||||||
text,
|
|
||||||
percent
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
[watched]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [slots, setSlots] = useState([])
|
|
||||||
const activityKeysRef = useRef(new Set())
|
|
||||||
activityKeysRef.current = new Set(activities.map((activity) => activity.key))
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSlots((prev) => {
|
|
||||||
const seen = new Set()
|
|
||||||
const next = []
|
|
||||||
|
|
||||||
for (const slot of prev) {
|
|
||||||
const fresh = activities.find((activity) => activity.key === slot.key)
|
|
||||||
if (fresh) {
|
|
||||||
next.push({ ...fresh, open: slot.open })
|
|
||||||
seen.add(slot.key)
|
|
||||||
} else {
|
|
||||||
next.push({ ...slot, open: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const activity of activities) {
|
|
||||||
if (seen.has(activity.key)) continue
|
|
||||||
next.push({ ...activity, open: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [activities])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const needsOpen = slots.some(
|
|
||||||
(slot) => !slot.open && activityKeysRef.current.has(slot.key)
|
|
||||||
)
|
|
||||||
if (!needsOpen) return undefined
|
|
||||||
|
|
||||||
const frame = window.requestAnimationFrame(() => {
|
|
||||||
setSlots((current) => {
|
|
||||||
const live = activityKeysRef.current
|
|
||||||
let changed = false
|
|
||||||
const next = current.map((slot) => {
|
|
||||||
if (!slot.open && live.has(slot.key)) {
|
|
||||||
changed = true
|
|
||||||
return { ...slot, open: true }
|
|
||||||
}
|
|
||||||
return slot
|
|
||||||
})
|
|
||||||
return changed ? next : current
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => window.cancelAnimationFrame(frame)
|
|
||||||
}, [slots])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const closing = slots.some(
|
|
||||||
(slot) => !slot.open && !activityKeysRef.current.has(slot.key)
|
|
||||||
)
|
|
||||||
if (!closing) return undefined
|
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
setSlots((current) =>
|
|
||||||
current.filter(
|
|
||||||
(slot) => slot.open || activityKeysRef.current.has(slot.key)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}, LIVE_ACTIVITY_TRANSITION_MS + 40)
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId)
|
|
||||||
}, [slots])
|
|
||||||
|
|
||||||
const size = token.controlHeight
|
|
||||||
const iconSize = Math.max(12, Math.round(size * 0.5))
|
|
||||||
const faviconIconSize = Math.round(size * 0.66)
|
|
||||||
const openCount = slots.filter((slot) => slot.open).length
|
|
||||||
const activitiesOpen = openCount > 0
|
|
||||||
|
|
||||||
const faviconActivity = useMemo(
|
|
||||||
() =>
|
|
||||||
activities.reduce((best, activity) => {
|
|
||||||
if (activity.percent == null) return best
|
|
||||||
if (!best || activity.percent > best.percent) return activity
|
|
||||||
return best
|
|
||||||
}, null),
|
|
||||||
[activities]
|
|
||||||
)
|
|
||||||
const faviconSourceRef = useRef(null)
|
|
||||||
const faviconCaptureSeq = useRef(0)
|
|
||||||
const faviconSignature = faviconActivity
|
|
||||||
? [
|
|
||||||
faviconActivity.key,
|
|
||||||
faviconActivity.percent,
|
|
||||||
faviconActivity.status,
|
|
||||||
size,
|
|
||||||
faviconIconSize,
|
|
||||||
FAVICON_PX,
|
|
||||||
token.colorText,
|
|
||||||
token.colorInfo,
|
|
||||||
token.colorSuccess,
|
|
||||||
token.colorWarning,
|
|
||||||
token.colorError
|
|
||||||
].join('|')
|
|
||||||
: ''
|
|
||||||
|
|
||||||
useEffect(() => () => restoreLiveFavicon(), [])
|
|
||||||
|
|
||||||
const appProgressPercent = faviconActivity?.percent ?? null
|
|
||||||
const appProgressStatus = faviconActivity?.status ?? null
|
|
||||||
const setAppProgressRef = useRef(setAppProgress)
|
|
||||||
setAppProgressRef.current = setAppProgress
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isElectron) return
|
|
||||||
void setAppProgressRef.current({
|
|
||||||
percent: appProgressPercent,
|
|
||||||
status: appProgressStatus
|
|
||||||
})
|
|
||||||
}, [isElectron, appProgressPercent, appProgressStatus])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isElectron) return undefined
|
|
||||||
return () => {
|
|
||||||
void setAppProgressRef.current({ percent: null })
|
|
||||||
}
|
|
||||||
}, [isElectron])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const seq = ++faviconCaptureSeq.current
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
faviconCaptureChain = faviconCaptureChain
|
|
||||||
.catch(() => undefined)
|
|
||||||
.then(async () => {
|
|
||||||
if (cancelled || seq !== faviconCaptureSeq.current) return
|
|
||||||
await waitForPaint()
|
|
||||||
if (cancelled || seq !== faviconCaptureSeq.current) return
|
|
||||||
|
|
||||||
const face = faviconSourceRef.current?.querySelector('.live-activity')
|
|
||||||
if (!face) {
|
|
||||||
restoreLiveFavicon()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const image = await snapdom.toSvg(face, {
|
|
||||||
width: FAVICON_PX,
|
|
||||||
height: FAVICON_PX,
|
|
||||||
embedFonts: false,
|
|
||||||
backgroundColor: null,
|
|
||||||
dpr: 1,
|
|
||||||
scale: 1,
|
|
||||||
outerTransforms: false,
|
|
||||||
invalidate: true
|
|
||||||
})
|
|
||||||
if (
|
|
||||||
cancelled ||
|
|
||||||
seq !== faviconCaptureSeq.current ||
|
|
||||||
!image?.src
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
applyLiveFavicon(image.src)
|
|
||||||
} catch (error) {
|
|
||||||
if (!cancelled && seq === faviconCaptureSeq.current) {
|
|
||||||
console.error('Failed to capture live activity favicon', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [faviconSignature])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`live-activities${activitiesOpen ? ' live-activities-open' : ''}`}
|
|
||||||
aria-hidden={activitiesOpen ? undefined : true}
|
|
||||||
style={{
|
|
||||||
width: openCount * size,
|
|
||||||
height: size,
|
|
||||||
marginTop: 2,
|
|
||||||
opacity: activitiesOpen ? 1 : 0,
|
|
||||||
pointerEvents: activitiesOpen ? undefined : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='live-activities-row'>
|
|
||||||
{slots.map((activity) => {
|
|
||||||
const tooltip =
|
|
||||||
activity.percent == null
|
|
||||||
? `${activity.name} · ${activity.text}`
|
|
||||||
: `${activity.name} · ${activity.text} ${activity.percent}%`
|
|
||||||
const open = () => {
|
|
||||||
if (!activity.open || typeof activity.model.url !== 'string')
|
|
||||||
return
|
|
||||||
navigate(
|
|
||||||
`${activity.model.url}/info?${activity.model.name}Id=${activity.id}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
key={activity.key}
|
|
||||||
content={
|
|
||||||
<SpotlightTooltip
|
|
||||||
query={`${activity.model.prefix}:${activity.id}`}
|
|
||||||
type={activity.model.name}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
trigger='hover'
|
|
||||||
placement='bottomRight'
|
|
||||||
arrow={false}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className='live-activity-slot'
|
|
||||||
style={{ width: activity.open ? size : 0 }}
|
|
||||||
onTransitionEnd={(event) => {
|
|
||||||
if (event.target !== event.currentTarget) return
|
|
||||||
if (event.propertyName !== 'width') return
|
|
||||||
if (activity.open) return
|
|
||||||
setSlots((current) =>
|
|
||||||
current.filter((slot) => slot.key !== activity.key)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderLiveActivityFace({
|
|
||||||
activity,
|
|
||||||
size,
|
|
||||||
iconSize,
|
|
||||||
label: tooltip,
|
|
||||||
tabIndex: activity.open ? 0 : -1,
|
|
||||||
onClick: open
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{faviconActivity
|
|
||||||
? createPortal(
|
|
||||||
<div
|
|
||||||
className='live-activity-favicon-source'
|
|
||||||
aria-hidden='true'
|
|
||||||
ref={faviconSourceRef}
|
|
||||||
style={{ width: size, height: size }}
|
|
||||||
>
|
|
||||||
{renderLiveActivityFace({
|
|
||||||
activity: faviconActivity,
|
|
||||||
size,
|
|
||||||
iconSize: faviconIconSize
|
|
||||||
})}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LiveActivities
|
|
||||||
@ -3,7 +3,6 @@ import { Form } from 'antd'
|
|||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { useMessageContext } from '../context/MessageContext'
|
import { useMessageContext } from '../context/MessageContext'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import get from 'lodash/get'
|
|
||||||
import set from 'lodash/set'
|
import set from 'lodash/set'
|
||||||
import { getModelByName } from '../../../database/ObjectModels'
|
import { getModelByName } from '../../../database/ObjectModels'
|
||||||
|
|
||||||
@ -12,24 +11,6 @@ import {
|
|||||||
stripNestedObjectProperties,
|
stripNestedObjectProperties,
|
||||||
calculateModelComputedEntries
|
calculateModelComputedEntries
|
||||||
} from '../utils/Utils'
|
} from '../utils/Utils'
|
||||||
import {
|
|
||||||
applyComputedEntries,
|
|
||||||
applyHydratedObjects,
|
|
||||||
applyRelatedObjectUpdates,
|
|
||||||
collectObjectPropertyRefs,
|
|
||||||
getReferenceId,
|
|
||||||
getRelatedObjectSubscriptionKey
|
|
||||||
} from '../utils/objectPropertyRefs'
|
|
||||||
|
|
||||||
const EMPTY_DEFAULT_VALUES = {}
|
|
||||||
|
|
||||||
const serializeDefaultValues = (value) => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value ?? null)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildObjectFromEntries = (entries = []) => {
|
const buildObjectFromEntries = (entries = []) => {
|
||||||
return entries.reduce((acc, entry) => {
|
return entries.reduce((acc, entry) => {
|
||||||
@ -45,17 +26,18 @@ const buildObjectFromEntries = (entries = []) => {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncFormValues = (form, data, refs = [], computedEntries = []) => {
|
// Patch computed fields onto the complete object.
|
||||||
refs.forEach((ref) => {
|
// This mirrors the handling used by ObjectForm.
|
||||||
form.setFieldValue(ref.namePath, get(data, ref.namePath))
|
const applyComputedEntries = (base, entries = []) => {
|
||||||
|
const result = mergeFormData(base || {})
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const { namePath, value } = entry || {}
|
||||||
|
if (!Array.isArray(namePath) || value === undefined) return
|
||||||
|
set(result, namePath, value)
|
||||||
})
|
})
|
||||||
|
|
||||||
computedEntries.forEach(({ namePath, value }) => {
|
return result
|
||||||
if (!Array.isArray(namePath) || value === undefined) return
|
|
||||||
if (form.getFieldValue(namePath) !== value) {
|
|
||||||
form.setFieldValue(namePath, value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -71,29 +53,13 @@ const syncFormValues = (form, data, refs = [], computedEntries = []) => {
|
|||||||
* loading, submitLoading, disabled, handleSubmit, form, formValid, objectData, setObjectData
|
* loading, submitLoading, disabled, handleSubmit, form, formValid, objectData, setObjectData
|
||||||
* }) => ReactNode
|
* }) => ReactNode
|
||||||
*/
|
*/
|
||||||
const NewObjectForm = ({
|
const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
|
||||||
type,
|
|
||||||
style,
|
|
||||||
defaultValues = EMPTY_DEFAULT_VALUES,
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
// Do not initialise this from defaultValues.
|
// Do not initialise this from defaultValues.
|
||||||
// The form initialisation effect below is the single source of truth,
|
// The form initialisation effect below is the single source of truth,
|
||||||
// matching ObjectForm's fetched-object handling.
|
// matching ObjectForm's fetched-object handling.
|
||||||
const [objectData, setObjectData] = useState({
|
const [objectData, setObjectData] = useState({
|
||||||
_isEditing: true
|
_isEditing: true
|
||||||
})
|
})
|
||||||
const objectDataRef = useRef(objectData)
|
|
||||||
const defaultValuesKey = serializeDefaultValues(defaultValues)
|
|
||||||
const defaultValuesRef = useRef(defaultValues)
|
|
||||||
defaultValuesRef.current = defaultValues
|
|
||||||
|
|
||||||
const commitObjectData = useCallback((value) => {
|
|
||||||
const next =
|
|
||||||
typeof value === 'function' ? value(objectDataRef.current) : value
|
|
||||||
objectDataRef.current = next
|
|
||||||
setObjectData(next)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const [submitLoading, setSubmitLoading] = useState(false)
|
const [submitLoading, setSubmitLoading] = useState(false)
|
||||||
const [formValid, setFormValid] = useState(false)
|
const [formValid, setFormValid] = useState(false)
|
||||||
@ -106,15 +72,7 @@ const NewObjectForm = ({
|
|||||||
|
|
||||||
const { showSuccess, showError: showMessageError } = useMessageContext()
|
const { showSuccess, showError: showMessageError } = useMessageContext()
|
||||||
|
|
||||||
const {
|
const { createObject, showError } = useContext(ApiServerContext)
|
||||||
createObject,
|
|
||||||
showError,
|
|
||||||
fetchObject,
|
|
||||||
subscribeToObjectUpdates,
|
|
||||||
connected
|
|
||||||
} = useContext(ApiServerContext)
|
|
||||||
const fetchObjectRef = useRef(fetchObject)
|
|
||||||
fetchObjectRef.current = fetchObject
|
|
||||||
|
|
||||||
const model = getModelByName(type)
|
const model = getModelByName(type)
|
||||||
|
|
||||||
@ -174,7 +132,7 @@ const NewObjectForm = ({
|
|||||||
* is important for object-select fields.
|
* is important for object-select fields.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initialData = mergeFormData(defaultValuesRef.current || {})
|
const initialData = mergeFormData(defaultValues || {})
|
||||||
|
|
||||||
const computedEntries = calculateComputedValues(initialData, model)
|
const computedEntries = calculateComputedValues(initialData, model)
|
||||||
|
|
||||||
@ -194,126 +152,10 @@ const NewObjectForm = ({
|
|||||||
form.setFieldsValue(initialFormData)
|
form.setFieldsValue(initialFormData)
|
||||||
|
|
||||||
// Keep objectData in sync with exactly the same initial data.
|
// Keep objectData in sync with exactly the same initial data.
|
||||||
commitObjectData(nextObjectData)
|
setObjectData(nextObjectData)
|
||||||
|
|
||||||
return validateForm()
|
return validateForm()
|
||||||
}, [
|
}, [form, defaultValues, calculateComputedValues, model, validateForm])
|
||||||
form,
|
|
||||||
defaultValuesKey,
|
|
||||||
calculateComputedValues,
|
|
||||||
model,
|
|
||||||
validateForm,
|
|
||||||
commitObjectData
|
|
||||||
])
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Default object and objectList values are often id stubs ({ _id }).
|
|
||||||
* Fetch the full records, then keep them current from object updates
|
|
||||||
* the same way ObjectForm does for the record it is editing.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const refs = collectObjectPropertyRefs(
|
|
||||||
defaultValuesRef.current,
|
|
||||||
model
|
|
||||||
).filter((ref) => !ref.hydrated)
|
|
||||||
|
|
||||||
if (refs.length === 0) return undefined
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
const seen = new Set()
|
|
||||||
const uniqueRefs = []
|
|
||||||
refs.forEach((ref) => {
|
|
||||||
const key = `${ref.objectType}:${ref.id.toLowerCase()}`
|
|
||||||
if (seen.has(key)) return
|
|
||||||
seen.add(key)
|
|
||||||
uniqueRefs.push(ref)
|
|
||||||
})
|
|
||||||
|
|
||||||
const hydrate = async () => {
|
|
||||||
const fetchedByKey = new Map()
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
uniqueRefs.map(async (ref) => {
|
|
||||||
const fullObject = await fetchObjectRef.current(ref.id, ref.objectType)
|
|
||||||
if (!getReferenceId(fullObject)) return
|
|
||||||
fetchedByKey.set(
|
|
||||||
`${ref.objectType}:${ref.id.toLowerCase()}`,
|
|
||||||
fullObject
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
const applied = applyHydratedObjects(
|
|
||||||
objectDataRef.current,
|
|
||||||
refs,
|
|
||||||
fetchedByKey,
|
|
||||||
model,
|
|
||||||
calculateComputedValues
|
|
||||||
)
|
|
||||||
if (!applied) return
|
|
||||||
|
|
||||||
syncFormValues(
|
|
||||||
form,
|
|
||||||
applied.next,
|
|
||||||
applied.refs,
|
|
||||||
applied.computedEntries
|
|
||||||
)
|
|
||||||
commitObjectData(applied.next)
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrate()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [defaultValuesKey, model, calculateComputedValues, form, commitObjectData])
|
|
||||||
|
|
||||||
const relatedObjectKey = getRelatedObjectSubscriptionKey(objectData, model)
|
|
||||||
|
|
||||||
// Keep referenced objects current while the form is open.
|
|
||||||
useEffect(() => {
|
|
||||||
if (connected !== true || !relatedObjectKey) return undefined
|
|
||||||
|
|
||||||
const unsubscribes = relatedObjectKey.split('|').map((entry) => {
|
|
||||||
const separator = entry.indexOf(':')
|
|
||||||
const objectType = entry.slice(0, separator)
|
|
||||||
const id = entry.slice(separator + 1)
|
|
||||||
|
|
||||||
return subscribeToObjectUpdates(id, objectType, (value) => {
|
|
||||||
const applied = applyRelatedObjectUpdates(
|
|
||||||
objectDataRef.current,
|
|
||||||
objectType,
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
model,
|
|
||||||
calculateComputedValues
|
|
||||||
)
|
|
||||||
if (!applied) return
|
|
||||||
|
|
||||||
syncFormValues(
|
|
||||||
form,
|
|
||||||
applied.next,
|
|
||||||
applied.refs,
|
|
||||||
applied.computedEntries
|
|
||||||
)
|
|
||||||
commitObjectData(applied.next)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribes.forEach((unsubscribe) => unsubscribe?.())
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
connected,
|
|
||||||
relatedObjectKey,
|
|
||||||
subscribeToObjectUpdates,
|
|
||||||
model,
|
|
||||||
calculateComputedValues,
|
|
||||||
form,
|
|
||||||
commitObjectData
|
|
||||||
])
|
|
||||||
|
|
||||||
// Validate form on change
|
// Validate form on change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -326,10 +168,7 @@ const NewObjectForm = ({
|
|||||||
|
|
||||||
const currentFormValues = form.getFieldsValue()
|
const currentFormValues = form.getFieldsValue()
|
||||||
|
|
||||||
const currentFormData = mergeFormData(
|
const currentFormData = mergeFormData(objectData || {}, currentFormValues)
|
||||||
objectDataRef.current || {},
|
|
||||||
currentFormValues
|
|
||||||
)
|
|
||||||
|
|
||||||
const computedEntries = calculateComputedValues(currentFormData, model)
|
const computedEntries = calculateComputedValues(currentFormData, model)
|
||||||
|
|
||||||
@ -370,10 +209,7 @@ const NewObjectForm = ({
|
|||||||
style={style}
|
style={style}
|
||||||
disabled={submitLoading}
|
disabled={submitLoading}
|
||||||
onValuesChange={(_changedValues, allFormValues) => {
|
onValuesChange={(_changedValues, allFormValues) => {
|
||||||
const currentFormData = mergeFormData(
|
const currentFormData = mergeFormData(objectData || {}, allFormValues)
|
||||||
objectDataRef.current || {},
|
|
||||||
allFormValues
|
|
||||||
)
|
|
||||||
|
|
||||||
const computedEntries = calculateComputedValues(currentFormData, model)
|
const computedEntries = calculateComputedValues(currentFormData, model)
|
||||||
|
|
||||||
@ -403,7 +239,7 @@ const NewObjectForm = ({
|
|||||||
|
|
||||||
allValues._isEditing = true
|
allValues._isEditing = true
|
||||||
|
|
||||||
commitObjectData(mergeFormData(objectDataRef.current, allValues))
|
setObjectData((prev) => mergeFormData(prev, allValues))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children({
|
{children({
|
||||||
@ -414,7 +250,7 @@ const NewObjectForm = ({
|
|||||||
form,
|
form,
|
||||||
formValid,
|
formValid,
|
||||||
objectData,
|
objectData,
|
||||||
setObjectData: commitObjectData
|
setObjectData
|
||||||
})}
|
})}
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -19,8 +19,7 @@ const ObjectDisplay = ({
|
|||||||
object,
|
object,
|
||||||
objectType,
|
objectType,
|
||||||
showHyperlink = false,
|
showHyperlink = false,
|
||||||
showSpotlight = true,
|
showSpotlight = true
|
||||||
onData
|
|
||||||
}) => {
|
}) => {
|
||||||
const [objectData, setObjectData] = useState(object)
|
const [objectData, setObjectData] = useState(object)
|
||||||
const [isHydrating, setIsHydrating] = useState(false)
|
const [isHydrating, setIsHydrating] = useState(false)
|
||||||
@ -29,18 +28,12 @@ const ObjectDisplay = ({
|
|||||||
const { token } = useContext(AuthContext)
|
const { token } = useContext(AuthContext)
|
||||||
const { callAction } = useActions()
|
const { callAction } = useActions()
|
||||||
const idRef = useRef(null)
|
const idRef = useRef(null)
|
||||||
const onDataRef = useRef(onData)
|
|
||||||
onDataRef.current = onData
|
|
||||||
|
|
||||||
// Update event handler
|
// Update event handler
|
||||||
const updateObjectEventHandler = useCallback((value) => {
|
const updateObjectEventHandler = useCallback((value) => {
|
||||||
setObjectData((prev) => merge({}, prev, value))
|
setObjectData((prev) => merge({}, prev, value))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onDataRef.current?.(objectData)
|
|
||||||
}, [objectData])
|
|
||||||
|
|
||||||
// Ensure ID is valid before hydrate/subscribe (non-empty, not null/undefined)
|
// Ensure ID is valid before hydrate/subscribe (non-empty, not null/undefined)
|
||||||
const isValidId = useCallback((id) => {
|
const isValidId = useCallback((id) => {
|
||||||
return id != null && String(id).trim() !== ''
|
return id != null && String(id).trim() !== ''
|
||||||
@ -278,8 +271,7 @@ ObjectDisplay.propTypes = {
|
|||||||
objectType: PropTypes.string,
|
objectType: PropTypes.string,
|
||||||
style: PropTypes.object,
|
style: PropTypes.object,
|
||||||
showHyperlink: PropTypes.bool,
|
showHyperlink: PropTypes.bool,
|
||||||
showSpotlight: PropTypes.bool,
|
showSpotlight: PropTypes.bool
|
||||||
onData: PropTypes.func
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ObjectDisplay
|
export default ObjectDisplay
|
||||||
|
|||||||
@ -282,10 +282,6 @@ const ObjectForm = forwardRef(
|
|||||||
const currentHasFilePropertiesRef = useRef(hasFileProperties)
|
const currentHasFilePropertiesRef = useRef(hasFileProperties)
|
||||||
const currentFlushOrphanFilesRef = useRef(flushOrphanFiles)
|
const currentFlushOrphanFilesRef = useRef(flushOrphanFiles)
|
||||||
const fetchedObjectRef = useRef(null)
|
const fetchedObjectRef = useRef(null)
|
||||||
const fetchInFlightRef = useRef(false)
|
|
||||||
const updatesDuringFetchRef = useRef([])
|
|
||||||
const hasCompletedFetchRef = useRef(false)
|
|
||||||
const loadedObjectKeyRef = useRef(null)
|
|
||||||
|
|
||||||
// Update refs when values change
|
// Update refs when values change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -484,17 +480,12 @@ const ObjectForm = forwardRef(
|
|||||||
.validateFields({ validateOnly: true })
|
.validateFields({ validateOnly: true })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setFormValid(true)
|
setFormValid(true)
|
||||||
// While viewing, objectData is published from the fetched/live
|
|
||||||
// record. Publishing the form snapshot here races a print start:
|
|
||||||
// the snapshot still has no current job and unmounts the job forms.
|
|
||||||
onStateChangeRef.current({
|
onStateChangeRef.current({
|
||||||
formValid: true,
|
formValid: true,
|
||||||
...(isEditingRef.current ? { objectData: mergedObjectData } : {}),
|
objectData: mergedObjectData,
|
||||||
editDisabled: getEditDisabled(
|
editDisabled: getEditDisabled(
|
||||||
model,
|
model,
|
||||||
isEditingRef.current
|
mergedObjectData,
|
||||||
? mergedObjectData
|
|
||||||
: serverObjectData.current || mergedObjectData,
|
|
||||||
userProfile
|
userProfile
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -503,12 +494,10 @@ const ObjectForm = forwardRef(
|
|||||||
setFormValid(false)
|
setFormValid(false)
|
||||||
onStateChangeRef.current({
|
onStateChangeRef.current({
|
||||||
formValid: false,
|
formValid: false,
|
||||||
...(isEditingRef.current ? { objectData: mergedObjectData } : {}),
|
objectData: mergedObjectData,
|
||||||
editDisabled: getEditDisabled(
|
editDisabled: getEditDisabled(
|
||||||
model,
|
model,
|
||||||
isEditingRef.current
|
mergedObjectData,
|
||||||
? mergedObjectData
|
|
||||||
: serverObjectData.current || mergedObjectData,
|
|
||||||
userProfile
|
userProfile
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -542,18 +531,6 @@ const ObjectForm = forwardRef(
|
|||||||
async (options = {}) => {
|
async (options = {}) => {
|
||||||
const objectKey = `${type}:${id}`
|
const objectKey = `${type}:${id}`
|
||||||
const skipObjectChildrenValue = options.skipObjectChildrenValue === true
|
const skipObjectChildrenValue = options.skipObjectChildrenValue === true
|
||||||
if (loadedObjectKeyRef.current !== objectKey) {
|
|
||||||
loadedObjectKeyRef.current = objectKey
|
|
||||||
hasCompletedFetchRef.current = false
|
|
||||||
}
|
|
||||||
// A print can assign currentJob before this request returns. Keep that
|
|
||||||
// live record for the first load; later refetches should take the server.
|
|
||||||
const liveBeforeFetch = hasCompletedFetchRef.current
|
|
||||||
? null
|
|
||||||
: serverObjectData.current
|
|
||||||
const updatesDuringFetch = []
|
|
||||||
updatesDuringFetchRef.current = updatesDuringFetch
|
|
||||||
fetchInFlightRef.current = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setFetchLoading(true)
|
setFetchLoading(true)
|
||||||
@ -561,10 +538,7 @@ const ObjectForm = forwardRef(
|
|||||||
const data = await fetchObject(id, type)
|
const data = await fetchObject(id, type)
|
||||||
const initialActivities = await fetchObjectActivities(id, type)
|
const initialActivities = await fetchObjectActivities(id, type)
|
||||||
|
|
||||||
if (
|
if (fetchedObjectRef.current !== objectKey) {
|
||||||
fetchedObjectRef.current !== objectKey ||
|
|
||||||
updatesDuringFetchRef.current !== updatesDuringFetch
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -577,39 +551,16 @@ const ObjectForm = forwardRef(
|
|||||||
releaseEditingState()
|
releaseEditingState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate and set computed values on initial load.
|
serverObjectData.current = data
|
||||||
// Updates that arrived while this request was in flight (a print
|
|
||||||
// starting and assigning currentJob/currentSubJob) must win over
|
// Calculate and set computed values on initial load
|
||||||
// the older fetched snapshot.
|
|
||||||
const computedEntries = calculateComputedValues(data, model, {
|
const computedEntries = calculateComputedValues(data, model, {
|
||||||
skipObjectChildrenValue
|
skipObjectChildrenValue
|
||||||
})
|
})
|
||||||
let initialFormData = applyComputedEntries(data, computedEntries)
|
const initialFormData = applyComputedEntries(data, computedEntries)
|
||||||
if (skipObjectChildrenValue) {
|
if (skipObjectChildrenValue) {
|
||||||
copyObjectChildren(initialFormData, data, model)
|
copyObjectChildren(initialFormData, data, model)
|
||||||
}
|
}
|
||||||
if (liveBeforeFetch) {
|
|
||||||
const liveId = liveBeforeFetch._id
|
|
||||||
const fetchedId = initialFormData?._id
|
|
||||||
if (
|
|
||||||
!liveId ||
|
|
||||||
!fetchedId ||
|
|
||||||
String(liveId).toLowerCase() === String(fetchedId).toLowerCase()
|
|
||||||
) {
|
|
||||||
initialFormData = mergeFormData(initialFormData, liveBeforeFetch)
|
|
||||||
if (!isEditingRef.current) {
|
|
||||||
copyObjectChildren(initialFormData, liveBeforeFetch, model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updatesDuringFetch.forEach((update) => {
|
|
||||||
initialFormData = mergeFormData(initialFormData, update)
|
|
||||||
if (!isEditingRef.current) {
|
|
||||||
copyObjectChildren(initialFormData, update, model)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
hasCompletedFetchRef.current = true
|
|
||||||
serverObjectData.current = initialFormData
|
|
||||||
setObjectData({
|
setObjectData({
|
||||||
...initialFormData,
|
...initialFormData,
|
||||||
_isEditing: isEditingRef.current
|
_isEditing: isEditingRef.current
|
||||||
@ -622,15 +573,6 @@ const ObjectForm = forwardRef(
|
|||||||
showError(
|
showError(
|
||||||
`Failed to fetch ${getModelMessageLabel(type).toLowerCase()} info.`
|
`Failed to fetch ${getModelMessageLabel(type).toLowerCase()} info.`
|
||||||
)
|
)
|
||||||
if (updatesDuringFetchRef.current === updatesDuringFetch) {
|
|
||||||
setFetchLoading(false)
|
|
||||||
onStateChangeRef.current({ loading: false })
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (updatesDuringFetchRef.current === updatesDuringFetch) {
|
|
||||||
fetchInFlightRef.current = false
|
|
||||||
updatesDuringFetchRef.current = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@ -650,21 +592,6 @@ const ObjectForm = forwardRef(
|
|||||||
// Update event handler
|
// Update event handler
|
||||||
const updateObjectEventHandler = useCallback(
|
const updateObjectEventHandler = useCallback(
|
||||||
(value) => {
|
(value) => {
|
||||||
if (
|
|
||||||
fetchInFlightRef.current &&
|
|
||||||
Array.isArray(updatesDuringFetchRef.current)
|
|
||||||
) {
|
|
||||||
updatesDuringFetchRef.current.push(value)
|
|
||||||
} else if (!isEditingRef.current) {
|
|
||||||
serverObjectData.current = mergeFormData(
|
|
||||||
serverObjectData.current,
|
|
||||||
value
|
|
||||||
)
|
|
||||||
if (serverObjectData.current) {
|
|
||||||
copyObjectChildren(serverObjectData.current, value, model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setObjectData((prev) => {
|
setObjectData((prev) => {
|
||||||
const next = mergeFormData(prev, value)
|
const next = mergeFormData(prev, value)
|
||||||
if (!isEditingRef.current) {
|
if (!isEditingRef.current) {
|
||||||
|
|||||||
@ -48,7 +48,7 @@ const ObjectInfo = ({
|
|||||||
items = items.filter((item) => item.required === required)
|
items = items.filter((item) => item.required === required)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showHyperlink !== undefined) {
|
if (showHyperlink) {
|
||||||
objectPropertyProps = { ...objectPropertyProps, showHyperlink }
|
objectPropertyProps = { ...objectPropertyProps, showHyperlink }
|
||||||
}
|
}
|
||||||
// Filter items based on visibleProperties
|
// Filter items based on visibleProperties
|
||||||
|
|||||||
@ -17,56 +17,6 @@ import { getModelProperty } from '../../../database/ObjectModels'
|
|||||||
const { SHOW_CHILD } = TreeSelect
|
const { SHOW_CHILD } = TreeSelect
|
||||||
|
|
||||||
const EMPTY_OBJECT = {}
|
const EMPTY_OBJECT = {}
|
||||||
const EMPTY_GROUP_TOKEN = '__null__'
|
|
||||||
|
|
||||||
// Null and omitted references share one token. JSON drops undefined, so the
|
|
||||||
// empty group has to use the same key the select expands with.
|
|
||||||
const getGroupValueToken = (value) => {
|
|
||||||
if (value == null || value === '' || value === EMPTY_GROUP_TOKEN) {
|
|
||||||
return EMPTY_GROUP_TOKEN
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((item) => getGroupValueToken(item)).join(',')
|
|
||||||
}
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
if (value._id != null && value._id !== '') return String(value._id)
|
|
||||||
if (value.name != null && value.name !== '') return String(value.name)
|
|
||||||
return EMPTY_GROUP_TOKEN
|
|
||||||
}
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupValuesMatch = (left, right) =>
|
|
||||||
getGroupValueToken(left) === getGroupValueToken(right)
|
|
||||||
|
|
||||||
// Property groups always have `property` and no top-level `_id`. An empty
|
|
||||||
// reference omits `value`, and must not be treated as a leaf.
|
|
||||||
const isPropertyGroup = (item) =>
|
|
||||||
Boolean(item?.property) && (item._id == null || item._id === '')
|
|
||||||
|
|
||||||
const buildSelectionPath = (fullValue, properties) => {
|
|
||||||
const valueFilter = {}
|
|
||||||
const pathKeys = []
|
|
||||||
const parentKeys = []
|
|
||||||
const looksComplete =
|
|
||||||
fullValue &&
|
|
||||||
typeof fullValue === 'object' &&
|
|
||||||
Object.keys(fullValue).some((key) => key !== '_id')
|
|
||||||
|
|
||||||
properties.forEach((prop) => {
|
|
||||||
const hasProp =
|
|
||||||
fullValue && Object.prototype.hasOwnProperty.call(fullValue, prop)
|
|
||||||
if (!hasProp && !looksComplete) return
|
|
||||||
|
|
||||||
const filterValue = hasProp ? fullValue[prop] : null
|
|
||||||
const valueString = getGroupValueToken(filterValue)
|
|
||||||
valueFilter[prop] = valueString
|
|
||||||
pathKeys.push(parentKeys.concat(`${prop}:${valueString}`).join('-'))
|
|
||||||
parentKeys.push(valueString)
|
|
||||||
})
|
|
||||||
|
|
||||||
return { valueFilter, pathKeys }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to check if two values are equal (handling objects/ids)
|
// Helper to check if two values are equal (handling objects/ids)
|
||||||
const areValuesEqual = (v1, v2) => {
|
const areValuesEqual = (v1, v2) => {
|
||||||
@ -184,7 +134,7 @@ const getBranchChildren = (data, filterPath) => {
|
|||||||
const match = nodeSpecificChildren.find(
|
const match = nodeSpecificChildren.find(
|
||||||
(group) =>
|
(group) =>
|
||||||
group.property === pathItem.property &&
|
group.property === pathItem.property &&
|
||||||
groupValuesMatch(group.value, pathItem.value)
|
areValuesEqual(group.value, pathItem.value)
|
||||||
)
|
)
|
||||||
if (match) {
|
if (match) {
|
||||||
nodeSpecificChildren = match.children
|
nodeSpecificChildren = match.children
|
||||||
@ -201,7 +151,7 @@ const replaceBranchChildren = (nodes, filterPath, newChildren, depth = 0) => {
|
|||||||
|
|
||||||
const { property, value } = filterPath[depth]
|
const { property, value } = filterPath[depth]
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
if (node.property !== property || !groupValuesMatch(node.value, value)) {
|
if (node.property !== property || !areValuesEqual(node.value, value)) {
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
const nextDepth = depth + 1
|
const nextDepth = depth + 1
|
||||||
@ -300,7 +250,6 @@ const ObjectSelect = ({
|
|||||||
const filterRef = useRef(filter)
|
const filterRef = useRef(filter)
|
||||||
const masterFilterRef = useRef(masterFilter)
|
const masterFilterRef = useRef(masterFilter)
|
||||||
const clearedMissingValueRef = useRef(false)
|
const clearedMissingValueRef = useRef(false)
|
||||||
const valueExpandRef = useRef(null)
|
|
||||||
const loadGenerationRef = useRef(0)
|
const loadGenerationRef = useRef(0)
|
||||||
const loadedKeysRef = useRef(new Set())
|
const loadedKeysRef = useRef(new Set())
|
||||||
const treeSelectValueRef = useRef(null)
|
const treeSelectValueRef = useRef(null)
|
||||||
@ -379,15 +328,22 @@ const ObjectSelect = ({
|
|||||||
const merged = [...current]
|
const merged = [...current]
|
||||||
|
|
||||||
// Helper to generate a unique key for a group node
|
// Helper to generate a unique key for a group node
|
||||||
const getGroupKey = (item) =>
|
const getGroupKey = (item) => {
|
||||||
`${item.property}:${getGroupValueToken(item.value)}`
|
const val = item.value
|
||||||
|
const valPart =
|
||||||
|
val && typeof val === 'object' && val._id
|
||||||
|
? val._id
|
||||||
|
: JSON.stringify(val)
|
||||||
|
return `${item.property}:${valPart}`
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of incoming) {
|
for (const item of incoming) {
|
||||||
if (isPropertyGroup(item)) {
|
if (item.property && item.value !== undefined) {
|
||||||
// It's a group node, including an empty reference with no value
|
// It's a group node
|
||||||
const itemKey = getGroupKey(item)
|
const itemKey = getGroupKey(item)
|
||||||
const existingIdx = merged.findIndex(
|
const existingIdx = merged.findIndex(
|
||||||
(x) => isPropertyGroup(x) && getGroupKey(x) === itemKey
|
(x) =>
|
||||||
|
x.property && x.value !== undefined && getGroupKey(x) === itemKey
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existingIdx > -1) {
|
if (existingIdx > -1) {
|
||||||
@ -550,7 +506,13 @@ const ObjectSelect = ({
|
|||||||
if (!group.property) return null
|
if (!group.property) return null
|
||||||
|
|
||||||
const { property, value, children } = group
|
const { property, value, children } = group
|
||||||
const valueString = getGroupValueToken(value)
|
var valueString = value
|
||||||
|
if (value && typeof value === 'object' && value._id) {
|
||||||
|
valueString = value._id
|
||||||
|
}
|
||||||
|
if (Array.isArray(valueString)) {
|
||||||
|
valueString = valueString.join(',')
|
||||||
|
}
|
||||||
const nodeKey = parentKeys
|
const nodeKey = parentKeys
|
||||||
.concat(property + ':' + valueString)
|
.concat(property + ':' + valueString)
|
||||||
.join('-')
|
.join('-')
|
||||||
@ -923,7 +885,6 @@ const ObjectSelect = ({
|
|||||||
loadedKeysRef.current = new Set()
|
loadedKeysRef.current = new Set()
|
||||||
setInitialized(false)
|
setInitialized(false)
|
||||||
valueRef.current = null
|
valueRef.current = null
|
||||||
valueExpandRef.current = null
|
|
||||||
setTreeSelectValue(null)
|
setTreeSelectValue(null)
|
||||||
setInitialLoading(true)
|
setInitialLoading(true)
|
||||||
setReloading(false)
|
setReloading(false)
|
||||||
@ -996,18 +957,6 @@ const ObjectSelect = ({
|
|||||||
getValueIdentity(valueRef.current) !== valueIdentity
|
getValueIdentity(valueRef.current) !== valueIdentity
|
||||||
) {
|
) {
|
||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
const loadedValues = multiple
|
|
||||||
? Array.isArray(value)
|
|
||||||
? value
|
|
||||||
: []
|
|
||||||
: [value]
|
|
||||||
const pathKeys = loadedValues.flatMap(
|
|
||||||
(item) => buildSelectionPath(item, properties).pathKeys
|
|
||||||
)
|
|
||||||
if (pathKeys.length > 0) {
|
|
||||||
setExpandedKeys([...new Set(pathKeys)])
|
|
||||||
pathKeys.forEach((key) => loadedKeysRef.current.add(key))
|
|
||||||
}
|
|
||||||
setTreeSelectValue(
|
setTreeSelectValue(
|
||||||
multiple
|
multiple
|
||||||
? ids.map((id) => toSelectValue(id)).filter((id) => id != null)
|
? ids.map((id) => toSelectValue(id)).filter((id) => id != null)
|
||||||
@ -1024,7 +973,6 @@ const ObjectSelect = ({
|
|||||||
getValueIdentity(valueRef.current) !== getValueIdentity(value) &&
|
getValueIdentity(valueRef.current) !== getValueIdentity(value) &&
|
||||||
type != 'unknown'
|
type != 'unknown'
|
||||||
) {
|
) {
|
||||||
valueExpandRef.current = getValueIdentity(value)
|
|
||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
const fullValues = await Promise.all(value.map(fetchFullObjectIfNeeded))
|
const fullValues = await Promise.all(value.map(fetchFullObjectIfNeeded))
|
||||||
if (generation !== loadGenerationRef.current) return
|
if (generation !== loadGenerationRef.current) return
|
||||||
@ -1036,24 +984,41 @@ const ObjectSelect = ({
|
|||||||
if (Array.isArray(data)) applyTreeFromData(data)
|
if (Array.isArray(data)) applyTreeFromData(data)
|
||||||
} else {
|
} else {
|
||||||
for (const fullValue of fullValues) {
|
for (const fullValue of fullValues) {
|
||||||
const { valueFilter, pathKeys: valuePathKeys } = buildSelectionPath(
|
const valueFilter = { ...filter }
|
||||||
fullValue,
|
const parentKeys = []
|
||||||
properties
|
|
||||||
)
|
|
||||||
pathKeys.push(...valuePathKeys)
|
|
||||||
|
|
||||||
const data = await handleFetchObjectsProperties({
|
properties.forEach((prop) => {
|
||||||
...filter,
|
if (!Object.prototype.hasOwnProperty.call(fullValue, prop)) return
|
||||||
...valueFilter
|
|
||||||
|
const filterValue = fullValue[prop]
|
||||||
|
let valueString = filterValue
|
||||||
|
if (
|
||||||
|
filterValue &&
|
||||||
|
typeof filterValue === 'object' &&
|
||||||
|
filterValue._id
|
||||||
|
) {
|
||||||
|
valueString = filterValue._id
|
||||||
|
} else if (filterValue?.name) {
|
||||||
|
valueString = filterValue.name
|
||||||
|
} else if (Array.isArray(filterValue)) {
|
||||||
|
valueString = filterValue.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
valueFilter[prop] = valueString
|
||||||
|
pathKeys.push(
|
||||||
|
parentKeys.concat(prop + ':' + valueString).join('-')
|
||||||
|
)
|
||||||
|
parentKeys.push(valueString)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const data = await handleFetchObjectsProperties(valueFilter)
|
||||||
if (generation !== loadGenerationRef.current) return
|
if (generation !== loadGenerationRef.current) return
|
||||||
if (Array.isArray(data)) applyTreeFromData(data)
|
if (Array.isArray(data)) applyTreeFromData(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandedPathKeys = [...new Set(pathKeys)]
|
setExpandedKeys([...new Set(pathKeys)])
|
||||||
expandedPathKeys.forEach((key) => loadedKeysRef.current.add(key))
|
pathKeys.forEach((key) => loadedKeysRef.current.add(key))
|
||||||
setExpandedKeys(expandedPathKeys)
|
|
||||||
setTreeSelectValue(
|
setTreeSelectValue(
|
||||||
value
|
value
|
||||||
.map((item) => toSelectValue(getValueId(item)))
|
.map((item) => toSelectValue(getValueId(item)))
|
||||||
@ -1069,29 +1034,51 @@ const ObjectSelect = ({
|
|||||||
getValueIdentity(valueRef.current) !== getValueIdentity(value) &&
|
getValueIdentity(valueRef.current) !== getValueIdentity(value) &&
|
||||||
type != 'unknown'
|
type != 'unknown'
|
||||||
) {
|
) {
|
||||||
valueExpandRef.current = getValueIdentity(value)
|
|
||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
const fullValue = await fetchFullObjectIfNeeded(value)
|
const fullValue = await fetchFullObjectIfNeeded(value)
|
||||||
if (generation !== loadGenerationRef.current) return
|
if (generation !== loadGenerationRef.current) return
|
||||||
const { valueFilter, pathKeys } = buildSelectionPath(
|
const valueFilter = { ...filter }
|
||||||
fullValue,
|
const pathKeys = []
|
||||||
properties
|
const parentKeys = []
|
||||||
)
|
properties.forEach((prop) => {
|
||||||
pathKeys.forEach((key) => loadedKeysRef.current.add(key))
|
if (Object.prototype.hasOwnProperty.call(fullValue, prop)) {
|
||||||
const data = await handleFetchObjectsProperties({
|
const filterValue = fullValue[prop]
|
||||||
...filter,
|
let valueString = filterValue
|
||||||
...valueFilter
|
if (
|
||||||
|
filterValue &&
|
||||||
|
typeof filterValue === 'object' &&
|
||||||
|
filterValue._id
|
||||||
|
) {
|
||||||
|
valueFilter[prop] = filterValue._id
|
||||||
|
valueString = filterValue._id
|
||||||
|
} else if (filterValue?.name) {
|
||||||
|
valueFilter[prop] = filterValue.name
|
||||||
|
valueString = filterValue.name
|
||||||
|
} else if (Array.isArray(filterValue)) {
|
||||||
|
valueFilter[prop] = filterValue.join(',')
|
||||||
|
valueString = filterValue.join(',')
|
||||||
|
} else {
|
||||||
|
valueFilter[prop] = filterValue
|
||||||
|
valueString = filterValue
|
||||||
|
}
|
||||||
|
const nodeKey = parentKeys
|
||||||
|
.concat(prop + ':' + valueString)
|
||||||
|
.join('-')
|
||||||
|
pathKeys.push(nodeKey)
|
||||||
|
parentKeys.push(valueString)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
setExpandedKeys(pathKeys)
|
||||||
|
pathKeys.forEach((key) => loadedKeysRef.current.add(key))
|
||||||
|
const data = await handleFetchObjectsProperties(valueFilter)
|
||||||
if (generation !== loadGenerationRef.current) return
|
if (generation !== loadGenerationRef.current) return
|
||||||
if (Array.isArray(data)) applyTreeFromData(data)
|
if (Array.isArray(data)) applyTreeFromData(data)
|
||||||
setExpandedKeys(pathKeys)
|
|
||||||
setTreeSelectValue(toSelectValue(valueRef.current._id))
|
setTreeSelectValue(toSelectValue(valueRef.current._id))
|
||||||
setInitialized(true)
|
setInitialized(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!initialized &&
|
!initialized &&
|
||||||
valueExpandRef.current == null &&
|
|
||||||
token != null &&
|
token != null &&
|
||||||
type != 'unknown' &&
|
type != 'unknown' &&
|
||||||
type != undefined &&
|
type != undefined &&
|
||||||
@ -1106,7 +1093,6 @@ const ObjectSelect = ({
|
|||||||
type == undefined ||
|
type == undefined ||
|
||||||
connected == false
|
connected == false
|
||||||
) {
|
) {
|
||||||
valueExpandRef.current = null
|
|
||||||
setTreeSelectValue(null)
|
setTreeSelectValue(null)
|
||||||
setInitialLoading(false)
|
setInitialLoading(false)
|
||||||
setInitialized(true)
|
setInitialized(true)
|
||||||
|
|||||||
@ -1,296 +0,0 @@
|
|||||||
import { useContext, useEffect, useRef, useState } from 'react'
|
|
||||||
import { Card, Flex, Spin } from 'antd'
|
|
||||||
import { LoadingOutlined } from '@ant-design/icons'
|
|
||||||
import PropTypes from 'prop-types'
|
|
||||||
import merge from 'lodash/merge'
|
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
|
||||||
import { AuthContext } from '../context/AuthContext'
|
|
||||||
import GCodePreview from './GCodeViewer'
|
|
||||||
import MissingPlaceholder from './MissingPlaceholder'
|
|
||||||
import ProgressDisplay from './ProgressDisplay'
|
|
||||||
import {
|
|
||||||
DEFAULT_UPDATE_INTERVAL_MS,
|
|
||||||
nextUpdateInterval
|
|
||||||
} from '../../Components/GCodePreview/GCodeViewer/tracking.js'
|
|
||||||
|
|
||||||
const emptyGcodePosition = {
|
|
||||||
position: [0, 0, 0, 0],
|
|
||||||
filePosition: 0,
|
|
||||||
fileSize: 0,
|
|
||||||
progress: 0,
|
|
||||||
isActive: false,
|
|
||||||
currentLayer: 0,
|
|
||||||
totalLayer: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Motion and gcodePosition are emitted back to back. Keep them as one sample
|
|
||||||
// so the preview doesn't start a move on the file position and then correct
|
|
||||||
// it when the toolhead position arrives a moment later.
|
|
||||||
const SAMPLE_BURST_MS = 100
|
|
||||||
const SAMPLE_FLUSH_MS = 40
|
|
||||||
|
|
||||||
function createTrackState() {
|
|
||||||
return {
|
|
||||||
gcodePosition: emptyGcodePosition,
|
|
||||||
livePosition: null,
|
|
||||||
receivedAt: 0,
|
|
||||||
intervalMs: DEFAULT_UPDATE_INTERVAL_MS,
|
|
||||||
burstAt: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toTrackSample(track) {
|
|
||||||
return {
|
|
||||||
gcodePosition: track.gcodePosition,
|
|
||||||
livePosition: track.livePosition,
|
|
||||||
receivedAt: track.receivedAt,
|
|
||||||
intervalMs: track.intervalMs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const PrinterGCodeFilePanel = ({ id, subJobId, offline = true }) => {
|
|
||||||
const { subscribeToObjectEvent, connected, fetchObject, fetchFileContent } =
|
|
||||||
useContext(ApiServerContext)
|
|
||||||
const { token } = useContext(AuthContext)
|
|
||||||
const apiRef = useRef({ fetchObject, fetchFileContent })
|
|
||||||
apiRef.current = { fetchObject, fetchFileContent }
|
|
||||||
const objectUrlRef = useRef(null)
|
|
||||||
|
|
||||||
const trackRef = useRef(createTrackState())
|
|
||||||
const flushRef = useRef(0)
|
|
||||||
const noteTrackRef = useRef(null)
|
|
||||||
const [trackSample, setTrackSample] = useState(() =>
|
|
||||||
toTrackSample(trackRef.current)
|
|
||||||
)
|
|
||||||
const [src, setSrc] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [downloadProgress, setDownloadProgress] = useState(null)
|
|
||||||
|
|
||||||
noteTrackRef.current = (patch) => {
|
|
||||||
const now = performance.now()
|
|
||||||
const track = trackRef.current
|
|
||||||
const sinceBurst = track.burstAt > 0 ? now - track.burstAt : Infinity
|
|
||||||
if (sinceBurst >= SAMPLE_BURST_MS) {
|
|
||||||
if (track.receivedAt > 0) {
|
|
||||||
track.intervalMs = nextUpdateInterval(
|
|
||||||
track.intervalMs,
|
|
||||||
now - track.receivedAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
track.receivedAt = now
|
|
||||||
}
|
|
||||||
track.burstAt = now
|
|
||||||
if (patch.gcodePosition) {
|
|
||||||
track.gcodePosition = merge({}, track.gcodePosition, patch.gcodePosition)
|
|
||||||
}
|
|
||||||
if (patch.livePosition !== undefined) {
|
|
||||||
track.livePosition = patch.livePosition
|
|
||||||
}
|
|
||||||
if (flushRef.current) clearTimeout(flushRef.current)
|
|
||||||
flushRef.current = window.setTimeout(() => {
|
|
||||||
flushRef.current = 0
|
|
||||||
setTrackSample(toTrackSample(trackRef.current))
|
|
||||||
}, SAMPLE_FLUSH_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (flushRef.current) {
|
|
||||||
clearTimeout(flushRef.current)
|
|
||||||
flushRef.current = 0
|
|
||||||
}
|
|
||||||
const fresh = createTrackState()
|
|
||||||
trackRef.current = fresh
|
|
||||||
setTrackSample(toTrackSample(fresh))
|
|
||||||
}, [id, offline])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (id && connected == true && offline == false) {
|
|
||||||
const printerId = id?.toLowerCase()
|
|
||||||
const gcodePositionUnsubscribe = subscribeToObjectEvent(
|
|
||||||
printerId,
|
|
||||||
'printer',
|
|
||||||
'gcodePosition',
|
|
||||||
(event) => {
|
|
||||||
noteTrackRef.current?.({ gcodePosition: event.data })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const motionUnsubscribe = subscribeToObjectEvent(
|
|
||||||
printerId,
|
|
||||||
'printer',
|
|
||||||
'motion',
|
|
||||||
(event) => {
|
|
||||||
const live = event.data?.livePosition
|
|
||||||
if (!Array.isArray(live) || live.length < 3) return
|
|
||||||
const prev = trackRef.current.livePosition
|
|
||||||
if (
|
|
||||||
prev &&
|
|
||||||
prev[0] === live[0] &&
|
|
||||||
prev[1] === live[1] &&
|
|
||||||
prev[2] === live[2]
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
noteTrackRef.current?.({
|
|
||||||
livePosition: [live[0], live[1], live[2]]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return () => {
|
|
||||||
if (flushRef.current) {
|
|
||||||
clearTimeout(flushRef.current)
|
|
||||||
flushRef.current = 0
|
|
||||||
}
|
|
||||||
if (gcodePositionUnsubscribe) gcodePositionUnsubscribe()
|
|
||||||
if (motionUnsubscribe) motionUnsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [id, connected, subscribeToObjectEvent, offline])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
const replaceSrc = (nextSrc) => {
|
|
||||||
if (objectUrlRef.current && objectUrlRef.current !== nextSrc) {
|
|
||||||
URL.revokeObjectURL(objectUrlRef.current)
|
|
||||||
}
|
|
||||||
objectUrlRef.current = nextSrc
|
|
||||||
setSrc(nextSrc)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadGcodeFile = async () => {
|
|
||||||
replaceSrc(null)
|
|
||||||
setError(null)
|
|
||||||
if (!subJobId || token == null) {
|
|
||||||
setLoading(false)
|
|
||||||
setDownloadProgress(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setDownloadProgress({ progress: 0, message: 'Loading G-code file...' })
|
|
||||||
const subJob = await apiRef.current.fetchObject(subJobId, 'subJob')
|
|
||||||
if (cancelled) return
|
|
||||||
const gcodeFileId = subJob?.gcodeFile?._id
|
|
||||||
if (!gcodeFileId) {
|
|
||||||
setLoading(false)
|
|
||||||
setDownloadProgress(null)
|
|
||||||
setError('No G-code file.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const gcodeFile = await apiRef.current.fetchObject(
|
|
||||||
gcodeFileId,
|
|
||||||
'gcodeFile'
|
|
||||||
)
|
|
||||||
if (cancelled) return
|
|
||||||
const file = gcodeFile?.file
|
|
||||||
if (!file?._id) {
|
|
||||||
setLoading(false)
|
|
||||||
setDownloadProgress(null)
|
|
||||||
setError('No G-code file.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectUrl = await apiRef.current.fetchFileContent(
|
|
||||||
file,
|
|
||||||
false,
|
|
||||||
(progress) => {
|
|
||||||
if (cancelled) return
|
|
||||||
setDownloadProgress(progress)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (cancelled) {
|
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!objectUrl) {
|
|
||||||
setLoading(false)
|
|
||||||
setDownloadProgress(null)
|
|
||||||
setError('Failed to load G-code file.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
replaceSrc(objectUrl)
|
|
||||||
setDownloadProgress(null)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
loadGcodeFile()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [subJobId, token])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (objectUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(objectUrlRef.current)
|
|
||||||
objectUrlRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!subJobId) {
|
|
||||||
return (
|
|
||||||
<MissingPlaceholder
|
|
||||||
message={'No sub job.'}
|
|
||||||
padding='24px'
|
|
||||||
hasBackground={false}
|
|
||||||
mutedBorder={true}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<MissingPlaceholder
|
|
||||||
message={error}
|
|
||||||
padding='24px'
|
|
||||||
hasBackground={false}
|
|
||||||
mutedBorder={true}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
{loading || !src ? (
|
|
||||||
<Flex align='center' justify='center' style={{ minHeight: 240 }}>
|
|
||||||
{downloadProgress ? (
|
|
||||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
|
||||||
<ProgressDisplay
|
|
||||||
percent={Math.round(
|
|
||||||
(Number(downloadProgress.progress) || 0) * 100
|
|
||||||
)}
|
|
||||||
status='active'
|
|
||||||
>
|
|
||||||
{downloadProgress.message || 'Downloading...'}
|
|
||||||
</ProgressDisplay>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Spin indicator={<LoadingOutlined spin />} size='large' />
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
) : (
|
|
||||||
<GCodePreview
|
|
||||||
src={src}
|
|
||||||
showFollowCurrentSubJob={true}
|
|
||||||
gcodePosition={trackSample.gcodePosition}
|
|
||||||
toolPosition={trackSample.livePosition}
|
|
||||||
sampleTime={trackSample.receivedAt}
|
|
||||||
updateIntervalMs={trackSample.intervalMs}
|
|
||||||
style={{ width: '100%', height: '60vh' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
PrinterGCodeFilePanel.propTypes = {
|
|
||||||
id: PropTypes.string,
|
|
||||||
subJobId: PropTypes.string,
|
|
||||||
offline: PropTypes.bool
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PrinterGCodeFilePanel
|
|
||||||
@ -1,14 +1,7 @@
|
|||||||
import { createElement, useContext, useEffect, useRef, useState } from 'react'
|
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { Button, Flex, Steps } from 'antd'
|
|
||||||
import ObjectDisplay from './ObjectDisplay'
|
import ObjectDisplay from './ObjectDisplay'
|
||||||
|
import { Steps } from 'antd'
|
||||||
import MissingPlaceholder from './MissingPlaceholder'
|
import MissingPlaceholder from './MissingPlaceholder'
|
||||||
import Tooltip from './Tooltip'
|
|
||||||
import { getModelByName } from '../../../database/ObjectModels'
|
|
||||||
import { findAction } from '../../../utils/modelActions'
|
|
||||||
import { hasActionPermission } from '../../../database/permissions'
|
|
||||||
import { AuthContext } from '../context/AuthContext'
|
|
||||||
import { useActions } from '../context/ActionsContext'
|
|
||||||
|
|
||||||
const getId = (item) => {
|
const getId = (item) => {
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
@ -24,96 +17,7 @@ const toObject = (item) => {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
const canCancelSubJob = (cancelAction, objectData, userProfile, model) => {
|
const PrinterQueue = ({ queue, currentSubJob }) => {
|
||||||
if (!cancelAction || !objectData) return false
|
|
||||||
if (!hasActionPermission(userProfile, model, cancelAction.name)) return false
|
|
||||||
if (typeof cancelAction.visible === 'function') {
|
|
||||||
if (!cancelAction.visible(objectData)) return false
|
|
||||||
} else if (cancelAction.visible === false) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (typeof cancelAction.disabled === 'function') {
|
|
||||||
return !cancelAction.disabled({ ...objectData, _user: userProfile })
|
|
||||||
}
|
|
||||||
return !cancelAction.disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
const PrinterQueueItem = ({ item, showHyperlink, printerId }) => {
|
|
||||||
const itemId = getId(item)
|
|
||||||
const [objectData, setObjectData] = useState(item)
|
|
||||||
const itemRef = useRef(item)
|
|
||||||
itemRef.current = item
|
|
||||||
const { userProfile } = useContext(AuthContext)
|
|
||||||
const { callAction } = useActions()
|
|
||||||
const model = getModelByName('subJob')
|
|
||||||
const cancelAction = findAction(model, 'cancel')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setObjectData(itemRef.current)
|
|
||||||
}, [itemId])
|
|
||||||
|
|
||||||
const showCancel = canCancelSubJob(
|
|
||||||
cancelAction,
|
|
||||||
objectData,
|
|
||||||
userProfile,
|
|
||||||
model
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleCancel = (event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
event.preventDefault()
|
|
||||||
if (!showCancel || !itemId) return
|
|
||||||
callAction(
|
|
||||||
cancelAction,
|
|
||||||
{
|
|
||||||
...objectData,
|
|
||||||
_id: itemId,
|
|
||||||
printer: objectData?.printer || printerId,
|
|
||||||
_user: userProfile
|
|
||||||
},
|
|
||||||
'subJob'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex align='center' gap={4} style={{ width: '100%', minWidth: 0 }}>
|
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<ObjectDisplay
|
|
||||||
object={item}
|
|
||||||
objectType='subJob'
|
|
||||||
showHyperlink={showHyperlink}
|
|
||||||
onData={setObjectData}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showCancel ? (
|
|
||||||
<Tooltip title={cancelAction.label}>
|
|
||||||
<Button
|
|
||||||
icon={
|
|
||||||
cancelAction.icon ? createElement(cancelAction.icon) : undefined
|
|
||||||
}
|
|
||||||
type='text'
|
|
||||||
size='small'
|
|
||||||
danger={cancelAction.danger || false}
|
|
||||||
onClick={handleCancel}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
PrinterQueueItem.propTypes = {
|
|
||||||
item: PropTypes.object,
|
|
||||||
showHyperlink: PropTypes.bool,
|
|
||||||
printerId: PropTypes.string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PrinterQueue = ({
|
|
||||||
queue,
|
|
||||||
currentSubJob,
|
|
||||||
showHyperlink = true,
|
|
||||||
printerId
|
|
||||||
}) => {
|
|
||||||
const queueItems = Array.isArray(queue)
|
const queueItems = Array.isArray(queue)
|
||||||
? queue.map(toObject).filter(Boolean)
|
? queue.map(toObject).filter(Boolean)
|
||||||
: []
|
: []
|
||||||
@ -148,14 +52,7 @@ const PrinterQueue = ({
|
|||||||
className='printerQueue'
|
className='printerQueue'
|
||||||
size='small'
|
size='small'
|
||||||
items={items.map((item) => ({
|
items={items.map((item) => ({
|
||||||
title: (
|
title: <ObjectDisplay object={item} objectType='subJob' showHyperlink />
|
||||||
<PrinterQueueItem
|
|
||||||
key={getId(item)}
|
|
||||||
item={item}
|
|
||||||
showHyperlink={showHyperlink}
|
|
||||||
printerId={printerId}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -163,9 +60,7 @@ const PrinterQueue = ({
|
|||||||
|
|
||||||
PrinterQueue.propTypes = {
|
PrinterQueue.propTypes = {
|
||||||
queue: PropTypes.array,
|
queue: PropTypes.array,
|
||||||
currentSubJob: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
|
currentSubJob: PropTypes.oneOfType([PropTypes.object, PropTypes.string])
|
||||||
showHyperlink: PropTypes.bool,
|
|
||||||
printerId: PropTypes.string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PrinterQueue
|
export default PrinterQueue
|
||||||
|
|||||||
@ -1,104 +1,34 @@
|
|||||||
// SpotlightTooltip.js
|
// SpotlightTooltip.js
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { Button, Card, Flex, Typography } from 'antd'
|
import { Descriptions, Card, Flex, Typography, Skeleton, Spin } from 'antd'
|
||||||
import {
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
createElement,
|
import { useEffect, useState, useContext, useCallback } from 'react'
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
useContext,
|
|
||||||
useCallback,
|
|
||||||
useMemo
|
|
||||||
} from 'react'
|
|
||||||
import { useLocation } from 'react-router-dom'
|
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
import { useActions } from '../context/ActionsContext'
|
import ObjectProperty from './ObjectProperty'
|
||||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||||
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
|
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import merge from 'lodash/merge'
|
import merge from 'lodash/merge'
|
||||||
import { getModelByName } from '../../../database/ObjectModels'
|
import {
|
||||||
import { hasActionPermission } from '../../../database/permissions'
|
getModelByName,
|
||||||
import ObjectCard from './ObjectCard'
|
getModelProperty
|
||||||
import Tooltip from './Tooltip'
|
} from '../../../database/ObjectModels'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
const hasValue = (value) => value !== undefined && value !== null && value !== ''
|
|
||||||
|
|
||||||
const getNestedValue = (obj, path) => {
|
|
||||||
return path.split('.').reduce((current, key) => current?.[key], obj)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SpotlightTooltip = ({ query, type }) => {
|
const SpotlightTooltip = ({ query, type }) => {
|
||||||
const [spotlightData, setSpotlightData] = useState(null)
|
const [spotlightData, setSpotlightData] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [initialized, setInitialized] = useState(false)
|
||||||
const isSlicerRoute = useLocation().pathname === '/slicer'
|
|
||||||
|
|
||||||
const { token, userProfile } = useContext(AuthContext)
|
const { token } = useContext(AuthContext)
|
||||||
const { callAction } = useActions()
|
|
||||||
const { fetchSpotlightData, subscribeToObjectUpdates, connected } =
|
const { fetchSpotlightData, subscribeToObjectUpdates, connected } =
|
||||||
useContext(ApiServerContext)
|
useContext(ApiServerContext)
|
||||||
|
|
||||||
|
// Get the model for this type
|
||||||
const model = getModelByName(type)
|
const model = getModelByName(type)
|
||||||
const modelProperties = model.properties || []
|
const modelProperties = model.properties || []
|
||||||
const rowActions = useMemo(() => {
|
|
||||||
if (isSlicerRoute) return []
|
|
||||||
return model.actions?.filter((action) => action.row == true) || []
|
|
||||||
}, [isSlicerRoute, model])
|
|
||||||
|
|
||||||
const renderActions = useCallback(
|
|
||||||
(objectData, actionsDisabled = false) => {
|
|
||||||
return (
|
|
||||||
<Flex gap='small' align='center' justify='center'>
|
|
||||||
{rowActions.map((action, index) => {
|
|
||||||
const denied = !hasActionPermission(userProfile, model, action.name)
|
|
||||||
var disabled = denied
|
|
||||||
if (action.disabled) {
|
|
||||||
if (typeof action.disabled === 'function') {
|
|
||||||
disabled =
|
|
||||||
denied ||
|
|
||||||
action.disabled({
|
|
||||||
...objectData,
|
|
||||||
_user: userProfile
|
|
||||||
}) ||
|
|
||||||
actionsDisabled
|
|
||||||
} else {
|
|
||||||
disabled = denied || action.disabled || actionsDisabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Tooltip key={index} title={action.label}>
|
|
||||||
<Button
|
|
||||||
icon={
|
|
||||||
action.icon ? (
|
|
||||||
createElement(action.icon)
|
|
||||||
) : (
|
|
||||||
<QuestionCircleIcon />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={disabled || objectData?.isSkeleton}
|
|
||||||
type={'text'}
|
|
||||||
size={'small'}
|
|
||||||
danger={action?.danger || false}
|
|
||||||
onClick={() => {
|
|
||||||
if (denied) return
|
|
||||||
callAction(
|
|
||||||
action,
|
|
||||||
{ ...objectData, _user: userProfile },
|
|
||||||
type
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[callAction, model, rowActions, type, userProfile]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// Update event handler
|
||||||
const updateObjectEventHandler = useCallback((value) => {
|
const updateObjectEventHandler = useCallback((value) => {
|
||||||
setSpotlightData((prev) => merge({}, prev, value))
|
setSpotlightData((prev) => merge({}, prev, value))
|
||||||
}, [])
|
}, [])
|
||||||
@ -117,6 +47,7 @@ const SpotlightTooltip = ({ query, type }) => {
|
|||||||
}
|
}
|
||||||
}, [token, fetchSpotlight, initialized])
|
}, [token, fetchSpotlight, initialized])
|
||||||
|
|
||||||
|
// Subscribe to object updates when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (spotlightData?._id && type && connected && token) {
|
if (spotlightData?._id && type && connected && token) {
|
||||||
const objectUpdatesUnsubscribe = subscribeToObjectUpdates(
|
const objectUpdatesUnsubscribe = subscribeToObjectUpdates(
|
||||||
@ -138,28 +69,6 @@ const SpotlightTooltip = ({ query, type }) => {
|
|||||||
updateObjectEventHandler
|
updateObjectEventHandler
|
||||||
])
|
])
|
||||||
|
|
||||||
const visibleColumns = useMemo(() => {
|
|
||||||
const columnNames = model.columns || []
|
|
||||||
const columns = { name: true }
|
|
||||||
|
|
||||||
if (!spotlightData) {
|
|
||||||
columnNames.slice(0, 4).forEach((colName) => {
|
|
||||||
columns[colName] = true
|
|
||||||
})
|
|
||||||
columnNames.forEach((colName) => {
|
|
||||||
if (columns[colName] == null) columns[colName] = false
|
|
||||||
})
|
|
||||||
return columns
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const colName of columnNames) {
|
|
||||||
if (colName === 'name') continue
|
|
||||||
columns[colName] = hasValue(getNestedValue(spotlightData, colName))
|
|
||||||
}
|
|
||||||
|
|
||||||
return columns
|
|
||||||
}, [model.columns, spotlightData])
|
|
||||||
|
|
||||||
if (!spotlightData && !loading) {
|
if (!spotlightData && !loading) {
|
||||||
return (
|
return (
|
||||||
<Card className='spotlight-tooltip'>
|
<Card className='spotlight-tooltip'>
|
||||||
@ -178,24 +87,68 @@ const SpotlightTooltip = ({ query, type }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to get nested property value
|
||||||
|
const getNestedValue = (obj, path) => {
|
||||||
|
return path.split('.').reduce((current, key) => current?.[key], obj)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='spotlight-tooltip' style={{ width: 360 }}>
|
<div className='spotlight-tooltip'>
|
||||||
<ObjectCard
|
<Spin indicator={<LoadingOutlined />} spinning={loading}>
|
||||||
isSkeleton={!spotlightData && loading}
|
<Descriptions bordered column={1} size='small'>
|
||||||
model={model}
|
{loading ? (
|
||||||
modelProperties={modelProperties}
|
<>
|
||||||
visibleColumns={visibleColumns}
|
<Descriptions.Item
|
||||||
record={
|
label={
|
||||||
spotlightData || {
|
<Skeleton.Input active size='small' style={{ width: 80 }} />
|
||||||
_id: query,
|
}
|
||||||
isSkeleton: true
|
>
|
||||||
}
|
<Skeleton.Input active size='small' style={{ width: 120 }} />
|
||||||
}
|
</Descriptions.Item>
|
||||||
isEditing={false}
|
<Descriptions.Item
|
||||||
rowActions={rowActions}
|
label={
|
||||||
renderActions={renderActions}
|
<Skeleton.Input active size='small' style={{ width: 80 }} />
|
||||||
cardStyle='borderless'
|
}
|
||||||
/>
|
>
|
||||||
|
<Skeleton.Input active size='small' style={{ width: 120 }} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={
|
||||||
|
<Skeleton.Input active size='small' style={{ width: 80 }} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Skeleton.Input active size='small' style={{ width: 120 }} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
modelProperties
|
||||||
|
.filter((prop) => {
|
||||||
|
const value = getNestedValue(spotlightData, prop.name)
|
||||||
|
return value !== undefined && value !== null && value !== ''
|
||||||
|
})
|
||||||
|
.map((prop) => {
|
||||||
|
const value = getNestedValue(spotlightData, prop.name)
|
||||||
|
return (
|
||||||
|
<Descriptions.Item key={prop.name} label={prop.label}>
|
||||||
|
<ObjectProperty
|
||||||
|
{...getModelProperty(type, prop.name)}
|
||||||
|
value={value}
|
||||||
|
objectData={spotlightData}
|
||||||
|
objectType={prop.objectType || type}
|
||||||
|
isEditing={false}
|
||||||
|
longId={false}
|
||||||
|
showSpotlight={false}
|
||||||
|
showLabel={false}
|
||||||
|
showName={false}
|
||||||
|
showId={false}
|
||||||
|
showQuantity={false}
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
DEFAULT_DATE_TIME_FORMAT,
|
DEFAULT_DATE_TIME_FORMAT,
|
||||||
splitDateTimeFormat
|
splitDateTimeFormat
|
||||||
} from '../../../database/Settings'
|
} from '../../../database/Settings'
|
||||||
import ElipsisText from './ElipsisText'
|
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@ -119,18 +118,9 @@ const TimeDisplay = ({
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex align={'center'} gap={'small'} wrap>
|
||||||
align={'center'}
|
{showDate || showTime ? <Text type={type}>{formattedDate}</Text> : null}
|
||||||
gap={'small'}
|
{showSince ? <Tag style={{ margin: 0 }}>{timeAgo}</Tag> : null}
|
||||||
wrap
|
|
||||||
style={{ minWidth: 0, maxWidth: '100%' }}
|
|
||||||
>
|
|
||||||
{showDate || showTime ? (
|
|
||||||
<ElipsisText type={type}>{formattedDate}</ElipsisText>
|
|
||||||
) : null}
|
|
||||||
{showSince ? (
|
|
||||||
<Tag style={{ margin: 0, flexShrink: 0 }}>{timeAgo}</Tag>
|
|
||||||
) : null}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,20 +24,7 @@ import { AuthContext } from './AuthContext'
|
|||||||
import { ElectronContext } from './ElectronContext'
|
import { ElectronContext } from './ElectronContext'
|
||||||
import { useNavigationTabs } from './NavigationTabsContext'
|
import { useNavigationTabs } from './NavigationTabsContext'
|
||||||
|
|
||||||
const noop = () => {}
|
const ActionsContext = createContext()
|
||||||
|
|
||||||
const defaultActions = {
|
|
||||||
currentObject: null,
|
|
||||||
currentObjectType: null,
|
|
||||||
setCurrentObject: noop,
|
|
||||||
setCurrentObjectType: noop,
|
|
||||||
callAction: noop,
|
|
||||||
clearAction: noop,
|
|
||||||
setOnModalOk: noop,
|
|
||||||
ctrlDown: false
|
|
||||||
}
|
|
||||||
|
|
||||||
const ActionsContext = createContext(defaultActions)
|
|
||||||
|
|
||||||
const ActionsProvider = ({ children }) => {
|
const ActionsProvider = ({ children }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
useMemo
|
useMemo
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import io from 'socket.io-client'
|
import io from 'socket.io-client'
|
||||||
import { bindStringIds } from '../../../utils/stringIds'
|
|
||||||
import { message, Modal, Space, Button, Typography, Flex } from 'antd'
|
import { message, Modal, Space, Button, Typography, Flex } from 'antd'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { AuthContext } from './AuthContext'
|
import { AuthContext } from './AuthContext'
|
||||||
@ -396,8 +395,6 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
const [userSettingsLoaded, setUserSettingsLoaded] = useState(false)
|
const [userSettingsLoaded, setUserSettingsLoaded] = useState(false)
|
||||||
const subscribedCallbacksRef = useRef(new Map())
|
const subscribedCallbacksRef = useRef(new Map())
|
||||||
const subscribedActivityCallbacksRef = useRef(new Map())
|
const subscribedActivityCallbacksRef = useRef(new Map())
|
||||||
const subscribedObjectUpdateCallbacksRef = useRef(new Map())
|
|
||||||
const subscribedObjectUpdateServerRef = useRef(new Set())
|
|
||||||
const subscribedActivityServerSubscriptionsRef = useRef(new Set())
|
const subscribedActivityServerSubscriptionsRef = useRef(new Set())
|
||||||
const notificationListenersRef = useRef(new Set())
|
const notificationListenersRef = useRef(new Set())
|
||||||
const [connectionIssue, setConnectionIssue] = useState(false)
|
const [connectionIssue, setConnectionIssue] = useState(false)
|
||||||
@ -447,7 +444,6 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
const clearSubscriptions = useCallback(() => {
|
const clearSubscriptions = useCallback(() => {
|
||||||
subscribedCallbacksRef.current.clear()
|
subscribedCallbacksRef.current.clear()
|
||||||
subscribedActivityServerSubscriptionsRef.current.clear()
|
subscribedActivityServerSubscriptionsRef.current.clear()
|
||||||
subscribedObjectUpdateServerRef.current.clear()
|
|
||||||
notificationListenersRef.current.clear()
|
notificationListenersRef.current.clear()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -508,45 +504,6 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const resubscribeObjectUpdateListeners = useCallback(
|
|
||||||
(socket = socketRef.current) => {
|
|
||||||
if (!socket?.connected) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribedObjectUpdateCallbacksRef.current.forEach(
|
|
||||||
(callbacks, callbacksRefKey) => {
|
|
||||||
if (!callbacks?.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (subscribedObjectUpdateServerRef.current.has(callbacksRefKey)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const colonIndex = callbacksRefKey.indexOf(':')
|
|
||||||
if (colonIndex === -1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectType = callbacksRefKey.slice(0, colonIndex)
|
|
||||||
const objectId = callbacksRefKey.slice(colonIndex + 1)
|
|
||||||
subscribedObjectUpdateServerRef.current.add(callbacksRefKey)
|
|
||||||
|
|
||||||
socket.emit(
|
|
||||||
'subscribeToObjectUpdate',
|
|
||||||
{ _id: objectId, objectType },
|
|
||||||
(result) => {
|
|
||||||
if (!result?.success) {
|
|
||||||
subscribedObjectUpdateServerRef.current.delete(callbacksRefKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const registerNotificationListener = useCallback((callback) => {
|
const registerNotificationListener = useCallback((callback) => {
|
||||||
notificationListenersRef.current.add(callback)
|
notificationListenersRef.current.add(callback)
|
||||||
return () => notificationListenersRef.current.delete(callback)
|
return () => notificationListenersRef.current.delete(callback)
|
||||||
@ -717,33 +674,25 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
|
|
||||||
const connectToServer = useCallback(() => {
|
const connectToServer = useCallback(() => {
|
||||||
if (token && authenticated == true) {
|
if (token && authenticated == true) {
|
||||||
if (socketRef.current?.authToken === token) {
|
if (socketRef.current) {
|
||||||
if (!socketRef.current.connected) {
|
if (!socketRef.current.connected) {
|
||||||
socketRef.current.connect()
|
socketRef.current.connect()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (socketRef.current) {
|
|
||||||
const previousSocket = socketRef.current
|
|
||||||
socketRef.current = null
|
|
||||||
previousSocket.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Token is available, connecting to api server...')
|
logger.debug('Token is available, connecting to api server...')
|
||||||
|
|
||||||
const newSocket = bindStringIds(
|
const newSocket = io(config.apiServerUrl, {
|
||||||
io(config.apiServerUrl, {
|
reconnection: false,
|
||||||
reconnection: false,
|
reconnectionAttempts: 0,
|
||||||
reconnectionAttempts: 0,
|
forceNew: true,
|
||||||
forceNew: true,
|
timeout: 3000,
|
||||||
timeout: 3000,
|
auth: { type: 'user' }
|
||||||
auth: { type: 'user' }
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
if (typeof newSocket.io?.reconnection === 'function') {
|
if (typeof newSocket.io?.reconnection === 'function') {
|
||||||
newSocket.io.reconnection(false)
|
newSocket.io.reconnection(false)
|
||||||
}
|
}
|
||||||
newSocket.authToken = token
|
|
||||||
|
|
||||||
setConnecting(true)
|
setConnecting(true)
|
||||||
|
|
||||||
@ -777,7 +726,6 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
setConnectionIssue(false)
|
setConnectionIssue(false)
|
||||||
resetReconnectState()
|
resetReconnectState()
|
||||||
resubscribeActivityListeners(newSocket)
|
resubscribeActivityListeners(newSocket)
|
||||||
resubscribeObjectUpdateListeners(newSocket)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -846,8 +794,7 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
getUserSettings,
|
getUserSettings,
|
||||||
resubscribeActivityListeners,
|
resubscribeActivityListeners,
|
||||||
resetReconnectState,
|
resetReconnectState,
|
||||||
markConnectionLost,
|
markConnectionLost
|
||||||
resubscribeObjectUpdateListeners
|
|
||||||
])
|
])
|
||||||
|
|
||||||
connectToServerRef.current = connectToServer
|
connectToServerRef.current = connectToServer
|
||||||
@ -911,10 +858,6 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
subscribedActivityServerSubscriptionsRef.current.clear()
|
subscribedActivityServerSubscriptionsRef.current.clear()
|
||||||
resubscribeActivityListeners(socket)
|
resubscribeActivityListeners(socket)
|
||||||
}
|
}
|
||||||
if (socket && subscribedObjectUpdateCallbacksRef.current.size > 0) {
|
|
||||||
subscribedObjectUpdateServerRef.current.clear()
|
|
||||||
resubscribeObjectUpdateListeners(socket)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWindowFocus = () => {
|
const handleWindowFocus = () => {
|
||||||
@ -928,7 +871,6 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
|
|
||||||
logger.debug('Page restored from bfcache, reconnecting socket...')
|
logger.debug('Page restored from bfcache, reconnecting socket...')
|
||||||
subscribedActivityServerSubscriptionsRef.current.clear()
|
subscribedActivityServerSubscriptionsRef.current.clear()
|
||||||
subscribedObjectUpdateServerRef.current.clear()
|
|
||||||
setConnected(false)
|
setConnected(false)
|
||||||
teardownSocket()
|
teardownSocket()
|
||||||
connectToServer()
|
connectToServer()
|
||||||
@ -948,7 +890,6 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
authenticated,
|
authenticated,
|
||||||
connectToServer,
|
connectToServer,
|
||||||
resubscribeActivityListeners,
|
resubscribeActivityListeners,
|
||||||
resubscribeObjectUpdateListeners,
|
|
||||||
attemptReconnect,
|
attemptReconnect,
|
||||||
teardownSocket
|
teardownSocket
|
||||||
])
|
])
|
||||||
@ -1000,27 +941,13 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
|
|
||||||
const handleObjectUpdate = async (data) => {
|
const handleObjectUpdate = async (data) => {
|
||||||
logger.debug('Notifying object update:', data)
|
logger.debug('Notifying object update:', data)
|
||||||
const id = data._id == null ? data._id : String(data._id).toLowerCase()
|
const id = data._id
|
||||||
const objectType = data.objectType
|
const objectType = data.objectType
|
||||||
|
|
||||||
const callbacksRefKey = `${objectType}:${id}`
|
const callbacksRefKey = `${objectType}:${id}`
|
||||||
const allCallbacksRefKey = getAllObjectUpdatesSubscriptionKey(objectType)
|
const allCallbacksRefKey = getAllObjectUpdatesSubscriptionKey(objectType)
|
||||||
|
|
||||||
const objectUpdateCallbacks =
|
if (
|
||||||
subscribedObjectUpdateCallbacksRef.current.get(callbacksRefKey)
|
|
||||||
if (id && objectType && objectUpdateCallbacks?.length) {
|
|
||||||
logger.debug(
|
|
||||||
`Calling ${objectUpdateCallbacks.length} callbacks for object:`,
|
|
||||||
callbacksRefKey
|
|
||||||
)
|
|
||||||
objectUpdateCallbacks.forEach((callback) => {
|
|
||||||
try {
|
|
||||||
callback(data.object)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error in object update callback:', error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else if (
|
|
||||||
id &&
|
id &&
|
||||||
objectType &&
|
objectType &&
|
||||||
subscribedCallbacksRef.current.has(callbacksRefKey)
|
subscribedCallbacksRef.current.has(callbacksRefKey)
|
||||||
@ -1176,32 +1103,28 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const offObjectUpdatesEvent = useCallback((id, objectType, callback) => {
|
const offObjectUpdatesEvent = useCallback((id, objectType, callback) => {
|
||||||
const normalizedId = id == null ? id : String(id).toLowerCase()
|
if (socketRef.current && socketRef.current.connected == true) {
|
||||||
const callbacksRefKey = `${objectType}:${normalizedId}`
|
const callbacksRefKey = `${objectType}:${id}`
|
||||||
if (!subscribedObjectUpdateCallbacksRef.current.has(callbacksRefKey)) {
|
// Remove callback from the subscribed callbacks map
|
||||||
return
|
if (subscribedCallbacksRef.current.has(callbacksRefKey)) {
|
||||||
}
|
const callbacks = subscribedCallbacksRef.current
|
||||||
|
.get(callbacksRefKey)
|
||||||
const callbacks = subscribedObjectUpdateCallbacksRef.current
|
.filter((cb) => cb !== callback)
|
||||||
.get(callbacksRefKey)
|
if (callbacks.length === 0) {
|
||||||
.filter((cb) => cb !== callback)
|
logger.debug(
|
||||||
|
'No callbacks found for object:',
|
||||||
if (callbacks.length === 0) {
|
callbacksRefKey,
|
||||||
subscribedObjectUpdateCallbacksRef.current.delete(callbacksRefKey)
|
'unsubscribing from object update...'
|
||||||
subscribedObjectUpdateServerRef.current.delete(callbacksRefKey)
|
)
|
||||||
if (socketRef.current?.connected) {
|
subscribedCallbacksRef.current.delete(callbacksRefKey)
|
||||||
logger.debug(
|
socketRef.current.emit('unsubscribeObjectUpdate', {
|
||||||
'No callbacks found for object:',
|
_id: id,
|
||||||
callbacksRefKey,
|
objectType: objectType
|
||||||
'unsubscribing from object update...'
|
})
|
||||||
)
|
} else {
|
||||||
socketRef.current.emit('unsubscribeObjectUpdate', {
|
subscribedCallbacksRef.current.set(callbacksRefKey, callbacks)
|
||||||
_id: normalizedId,
|
}
|
||||||
objectType
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
subscribedObjectUpdateCallbacksRef.current.set(callbacksRefKey, callbacks)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -1255,55 +1178,43 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
|
|
||||||
const subscribeToObjectUpdates = useCallback(
|
const subscribeToObjectUpdates = useCallback(
|
||||||
(id, objectType, callback) => {
|
(id, objectType, callback) => {
|
||||||
if (!id || !objectType || typeof callback !== 'function') {
|
if (socketRef.current && socketRef.current.connected == true) {
|
||||||
return undefined
|
const callbacksRefKey = `${objectType}:${id}`
|
||||||
}
|
// Add callback to the subscribed callbacks map immediately
|
||||||
|
if (!subscribedCallbacksRef.current.has(callbacksRefKey)) {
|
||||||
|
subscribedCallbacksRef.current.set(callbacksRefKey, [])
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedId = String(id).toLowerCase()
|
const callbacksLength =
|
||||||
const callbacksRefKey = `${objectType}:${normalizedId}`
|
subscribedCallbacksRef.current.get(callbacksRefKey).length
|
||||||
if (!subscribedObjectUpdateCallbacksRef.current.has(callbacksRefKey)) {
|
|
||||||
subscribedObjectUpdateCallbacksRef.current.set(callbacksRefKey, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbacks =
|
if (callbacksLength <= 0) {
|
||||||
subscribedObjectUpdateCallbacksRef.current.get(callbacksRefKey)
|
socketRef.current.emit(
|
||||||
callbacks.push(callback)
|
'subscribeToObjectUpdate',
|
||||||
|
{
|
||||||
const socket = socketRef.current
|
_id: id,
|
||||||
if (
|
objectType: objectType
|
||||||
socket?.connected &&
|
},
|
||||||
!subscribedObjectUpdateServerRef.current.has(callbacksRefKey)
|
(result) => {
|
||||||
) {
|
if (result.success) {
|
||||||
socket.emit(
|
logger.info('Subscribed to id:', id, 'objectType:', objectType)
|
||||||
'subscribeToObjectUpdate',
|
}
|
||||||
{
|
|
||||||
_id: normalizedId,
|
|
||||||
objectType
|
|
||||||
},
|
|
||||||
(result) => {
|
|
||||||
if (result?.success) {
|
|
||||||
subscribedObjectUpdateServerRef.current.add(callbacksRefKey)
|
|
||||||
logger.info(
|
|
||||||
'Subscribed to id:',
|
|
||||||
normalizedId,
|
|
||||||
'objectType:',
|
|
||||||
objectType
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
'Adding callback id:',
|
||||||
|
id,
|
||||||
|
'objectType:',
|
||||||
|
objectType,
|
||||||
|
'callbacks length:',
|
||||||
|
callbacksLength + 1
|
||||||
)
|
)
|
||||||
|
subscribedCallbacksRef.current.get(callbacksRefKey).push(callback)
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => offObjectUpdatesEvent(id, objectType, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'Adding callback id:',
|
|
||||||
id,
|
|
||||||
'objectType:',
|
|
||||||
objectType,
|
|
||||||
'callbacks length:',
|
|
||||||
callbacks.length
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => offObjectUpdatesEvent(id, objectType, callback)
|
|
||||||
},
|
},
|
||||||
[offObjectUpdatesEvent]
|
[offObjectUpdatesEvent]
|
||||||
)
|
)
|
||||||
@ -1636,18 +1547,16 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
const showError = useCallback(
|
const showError = useCallback(
|
||||||
(error, callback = null) => {
|
(error, callback = null) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
const responseData = error?.response?.data
|
console.error(error.response)
|
||||||
const data =
|
console.error(error.response.data)
|
||||||
responseData &&
|
console.error(error.response.data.code)
|
||||||
typeof responseData === 'object' &&
|
console.error(error.response.data.error)
|
||||||
!(responseData instanceof Blob)
|
console.error(error.response.data.message)
|
||||||
? responseData
|
console.error(error.response.data.status)
|
||||||
: null
|
console.error(error.response.data.timestamp)
|
||||||
if (data) {
|
console.error(error.response.data.path)
|
||||||
console.error(error.response)
|
console.error(error.response.data.method)
|
||||||
console.error(data)
|
const code = error.response.data.code || 'UNKNOWN'
|
||||||
}
|
|
||||||
const code = data?.code || 'UNKNOWN'
|
|
||||||
if (code == 'UNAUTHORIZED') {
|
if (code == 'UNAUTHORIZED') {
|
||||||
if (token) {
|
if (token) {
|
||||||
setUnauthenticated()
|
setUnauthenticated()
|
||||||
@ -1668,8 +1577,8 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var content = `Error ${error.code} (${error.status}): ${error.message}`
|
var content = `Error ${error.code} (${error.status}): ${error.message}`
|
||||||
if (data?.error) {
|
if (error.response?.data?.error) {
|
||||||
content = `${data.error} (${error.status})`
|
content = `${error.response?.data?.error} (${error.status})`
|
||||||
}
|
}
|
||||||
setErrorModalContent(content)
|
setErrorModalContent(content)
|
||||||
setRetryCallback(() => callback)
|
setRetryCallback(() => callback)
|
||||||
@ -2187,8 +2096,6 @@ const ApiServerProvider = ({ children }) => {
|
|||||||
return fileURL
|
return fileURL
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
if (err?.code === 'ERR_CANCELED') return
|
|
||||||
await normalizeAxiosBlobError(err)
|
|
||||||
showError(err, () => {
|
showError(err, () => {
|
||||||
fetchFileContent(file, download, onProgress)
|
fetchFileContent(file, download, onProgress)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -30,9 +30,6 @@ import {
|
|||||||
getAuthCookies,
|
getAuthCookies,
|
||||||
setAuthCookies,
|
setAuthCookies,
|
||||||
clearAuthCookies,
|
clearAuthCookies,
|
||||||
getSlicerAuthSession,
|
|
||||||
setSlicerAuthSession,
|
|
||||||
clearSlicerAuthSession,
|
|
||||||
areCookiesEnabled,
|
areCookiesEnabled,
|
||||||
validateAuthCookies,
|
validateAuthCookies,
|
||||||
setupCookieSync,
|
setupCookieSync,
|
||||||
@ -46,13 +43,6 @@ const AuthContext = createContext()
|
|||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
const pendingLoginTokenRequests = new Map()
|
|
||||||
|
|
||||||
const getSlicerAppKey = (location) => {
|
|
||||||
if (location?.pathname !== '/slicer') return null
|
|
||||||
return new URLSearchParams(location.search).get('authCode') || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractUserFromAuthData = (authData) => {
|
const extractUserFromAuthData = (authData) => {
|
||||||
if (!authData || typeof authData !== 'object') return null
|
if (!authData || typeof authData !== 'object') return null
|
||||||
if (authData.user && typeof authData.user === 'object') return authData.user
|
if (authData.user && typeof authData.user === 'object') return authData.user
|
||||||
@ -71,30 +61,6 @@ const AuthProvider = ({ children }) => {
|
|||||||
const [notificationApi, notificationContextHolder] =
|
const [notificationApi, notificationContextHolder] =
|
||||||
notification.useNotification()
|
notification.useNotification()
|
||||||
const [initialWebAuth] = useState(() => {
|
const [initialWebAuth] = useState(() => {
|
||||||
// The slicer opens through a one-time app-password link. Never reuse a
|
|
||||||
// saved FarmControl login, which expires and replaces that session.
|
|
||||||
if (window.location.pathname === '/slicer') {
|
|
||||||
const hasAuthCode = new URLSearchParams(window.location.search).has(
|
|
||||||
'authCode'
|
|
||||||
)
|
|
||||||
const slicerSession = hasAuthCode ? null : getSlicerAuthSession()
|
|
||||||
if (slicerSession?.token) {
|
|
||||||
return {
|
|
||||||
retrieved: true,
|
|
||||||
token: slicerSession.token,
|
|
||||||
expiresAt: slicerSession.expiresAt,
|
|
||||||
user: slicerSession.user,
|
|
||||||
authenticated: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
retrieved: true,
|
|
||||||
token: null,
|
|
||||||
expiresAt: null,
|
|
||||||
user: null,
|
|
||||||
authenticated: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (detectIsElectron()) {
|
if (detectIsElectron()) {
|
||||||
return { retrieved: false }
|
return { retrieved: false }
|
||||||
}
|
}
|
||||||
@ -147,10 +113,6 @@ const AuthProvider = ({ children }) => {
|
|||||||
} = useContext(ElectronContext)
|
} = useContext(ElectronContext)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const slicerAuthPendingRef = useRef(Boolean(getSlicerAppKey(location)))
|
|
||||||
const [slicerAuthPending, setSlicerAuthPending] = useState(
|
|
||||||
slicerAuthPendingRef.current
|
|
||||||
)
|
|
||||||
const locationRef = useRef(location)
|
const locationRef = useRef(location)
|
||||||
locationRef.current = location
|
locationRef.current = location
|
||||||
const sessionRef = useRef({
|
const sessionRef = useRef({
|
||||||
@ -161,13 +123,6 @@ const AuthProvider = ({ children }) => {
|
|||||||
})
|
})
|
||||||
sessionRef.current = { token, expiresAt, userProfile, authenticated }
|
sessionRef.current = { token, expiresAt, userProfile, authenticated }
|
||||||
const isDesktopHelperRoute = location.pathname.startsWith('/desktop')
|
const isDesktopHelperRoute = location.pathname.startsWith('/desktop')
|
||||||
const isSlicerRoute = location.pathname === '/slicer'
|
|
||||||
const slicerAppKey = getSlicerAppKey(location)
|
|
||||||
const suppressUnauthorizedModal =
|
|
||||||
isDesktopHelperRoute ||
|
|
||||||
isSlicerRoute ||
|
|
||||||
Boolean(slicerAppKey) ||
|
|
||||||
slicerAuthPending
|
|
||||||
|
|
||||||
var redirectType = 'web'
|
var redirectType = 'web'
|
||||||
|
|
||||||
@ -214,13 +169,6 @@ const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
const persistSession = useCallback(
|
const persistSession = useCallback(
|
||||||
async ({ token: nextToken, expiresAt: nextExpiresAt, user: nextUser }) => {
|
async ({ token: nextToken, expiresAt: nextExpiresAt, user: nextUser }) => {
|
||||||
if (locationRef.current?.pathname === '/slicer') {
|
|
||||||
return setSlicerAuthSession({
|
|
||||||
access_token: nextToken,
|
|
||||||
expires_at: nextExpiresAt,
|
|
||||||
user: nextUser
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
return await setAuthSession({
|
return await setAuthSession({
|
||||||
token: nextToken,
|
token: nextToken,
|
||||||
@ -238,9 +186,6 @@ const AuthProvider = ({ children }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const clearPersistedSession = useCallback(async () => {
|
const clearPersistedSession = useCallback(async () => {
|
||||||
if (locationRef.current?.pathname === '/slicer') {
|
|
||||||
return clearSlicerAuthSession()
|
|
||||||
}
|
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
return await clearAuthSession()
|
return await clearAuthSession()
|
||||||
}
|
}
|
||||||
@ -324,19 +269,13 @@ const AuthProvider = ({ children }) => {
|
|||||||
// An auth code represents a newly completed login and must take precedence
|
// An auth code represents a newly completed login and must take precedence
|
||||||
// over any existing persisted session.
|
// over any existing persisted session.
|
||||||
if (
|
if (
|
||||||
location.pathname === '/slicer' ||
|
|
||||||
location.pathname === '/applaunch' ||
|
location.pathname === '/applaunch' ||
|
||||||
location.pathname.startsWith('/desktop') ||
|
location.pathname.startsWith('/desktop') ||
|
||||||
authCode != null
|
authCode != null
|
||||||
) {
|
) {
|
||||||
if (
|
if (location.pathname.startsWith('/desktop')) {
|
||||||
location.pathname === '/slicer' ||
|
|
||||||
location.pathname.startsWith('/desktop')
|
|
||||||
) {
|
|
||||||
setRetreivedTokenFromCookies(true)
|
setRetreivedTokenFromCookies(true)
|
||||||
if (location.pathname.startsWith('/desktop')) {
|
setInitialized(true)
|
||||||
setInitialized(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -353,7 +292,6 @@ const AuthProvider = ({ children }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isElectron) return
|
if (isElectron) return
|
||||||
const cleanupCookieSync = setupCookieSync(() => {
|
const cleanupCookieSync = setupCookieSync(() => {
|
||||||
if (locationRef.current?.pathname === '/slicer') return
|
|
||||||
try {
|
try {
|
||||||
if (validateAuthCookies()) {
|
if (validateAuthCookies()) {
|
||||||
const {
|
const {
|
||||||
@ -520,17 +458,10 @@ const AuthProvider = ({ children }) => {
|
|||||||
setShowUnauthorizedModal(false)
|
setShowUnauthorizedModal(false)
|
||||||
setShowSessionExpiredModal(false)
|
setShowSessionExpiredModal(false)
|
||||||
setAuthError(null)
|
setAuthError(null)
|
||||||
|
try {
|
||||||
let request = pendingLoginTokenRequests.get(code)
|
const response = await axios.get(
|
||||||
if (!request) {
|
|
||||||
request = axios.get(
|
|
||||||
`${config.backendUrl}/auth/${redirectType}/token?code=${code}`
|
`${config.backendUrl}/auth/${redirectType}/token?code=${code}`
|
||||||
)
|
)
|
||||||
pendingLoginTokenRequests.set(code, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await request
|
|
||||||
|
|
||||||
if (response.status === 200 && response.data) {
|
if (response.status === 200 && response.data) {
|
||||||
logger.debug('Got auth token!')
|
logger.debug('Got auth token!')
|
||||||
@ -582,8 +513,6 @@ const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
setAuthenticated(false)
|
setAuthenticated(false)
|
||||||
} finally {
|
} finally {
|
||||||
slicerAuthPendingRef.current = false
|
|
||||||
setSlicerAuthPending(false)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRetreivedTokenFromCookies(true)
|
setRetreivedTokenFromCookies(true)
|
||||||
}
|
}
|
||||||
@ -653,11 +582,7 @@ const AuthProvider = ({ children }) => {
|
|||||||
if (hadSession) {
|
if (hadSession) {
|
||||||
clearPersistedSession()
|
clearPersistedSession()
|
||||||
}
|
}
|
||||||
if (
|
if (showSessionExpiredModal == false) {
|
||||||
showSessionExpiredModal == false &&
|
|
||||||
!slicerAuthPendingRef.current &&
|
|
||||||
!getSlicerAppKey(locationRef.current)
|
|
||||||
) {
|
|
||||||
setShowUnauthorizedModal(true)
|
setShowUnauthorizedModal(true)
|
||||||
}
|
}
|
||||||
}, [clearPersistedSession, showSessionExpiredModal])
|
}, [clearPersistedSession, showSessionExpiredModal])
|
||||||
@ -706,10 +631,6 @@ const AuthProvider = ({ children }) => {
|
|||||||
let intervalId
|
let intervalId
|
||||||
|
|
||||||
const tokenRefresh = () => {
|
const tokenRefresh = () => {
|
||||||
if (locationRef.current?.pathname === '/slicer') {
|
|
||||||
notificationApi.destroy('token-expiration')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (expiresAt) {
|
if (expiresAt) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const expirationDate = new Date(expiresAt)
|
const expirationDate = new Date(expiresAt)
|
||||||
@ -847,25 +768,13 @@ const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
const authCode =
|
const authCode =
|
||||||
new URLSearchParams(location.search).get('authCode') || null
|
new URLSearchParams(location.search).get('authCode') || null
|
||||||
const slicerKey = getSlicerAppKey(location)
|
|
||||||
if (
|
if (
|
||||||
authCode != null &&
|
authCode != null &&
|
||||||
processedAuthCodeRef.current !== authCode
|
processedAuthCodeRef.current !== authCode
|
||||||
) {
|
) {
|
||||||
processedAuthCodeRef.current = authCode
|
processedAuthCodeRef.current = authCode
|
||||||
if (slicerKey) {
|
|
||||||
slicerAuthPendingRef.current = true
|
|
||||||
setSlicerAuthPending(true)
|
|
||||||
setShowUnauthorizedModal(false)
|
|
||||||
setInitialized(true)
|
|
||||||
}
|
|
||||||
getLoginToken(authCode)
|
getLoginToken(authCode)
|
||||||
return
|
} else if (
|
||||||
}
|
|
||||||
if (slicerAuthPendingRef.current || slicerKey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
token == null &&
|
token == null &&
|
||||||
retreivedTokenFromCookies == true &&
|
retreivedTokenFromCookies == true &&
|
||||||
initialized == false &&
|
initialized == false &&
|
||||||
@ -877,9 +786,10 @@ const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
checkAuthStatus,
|
checkAuthStatus,
|
||||||
location,
|
location.search,
|
||||||
getLoginToken,
|
getLoginToken,
|
||||||
initialized,
|
initialized,
|
||||||
|
location.pathname,
|
||||||
navigate,
|
navigate,
|
||||||
token,
|
token,
|
||||||
retreivedTokenFromCookies
|
retreivedTokenFromCookies
|
||||||
@ -930,7 +840,6 @@ const AuthProvider = ({ children }) => {
|
|||||||
open={
|
open={
|
||||||
showSessionExpiredModal &&
|
showSessionExpiredModal &&
|
||||||
!isDesktopHelperRoute &&
|
!isDesktopHelperRoute &&
|
||||||
!isSlicerRoute &&
|
|
||||||
!loading &&
|
!loading &&
|
||||||
!showAuthErrorModal
|
!showAuthErrorModal
|
||||||
}
|
}
|
||||||
@ -961,7 +870,7 @@ const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
open={
|
open={
|
||||||
showUnauthorizedModal &&
|
showUnauthorizedModal &&
|
||||||
!suppressUnauthorizedModal &&
|
!isDesktopHelperRoute &&
|
||||||
!authenticated &&
|
!authenticated &&
|
||||||
!loading &&
|
!loading &&
|
||||||
!showAuthErrorModal
|
!showAuthErrorModal
|
||||||
|
|||||||
@ -365,15 +365,6 @@ const ElectronProvider = ({ children }) => {
|
|||||||
[electronAvailable]
|
[electronAvailable]
|
||||||
)
|
)
|
||||||
|
|
||||||
const setAppProgress = useCallback(
|
|
||||||
async (progress) => {
|
|
||||||
if (!electronAvailable) return false
|
|
||||||
const result = await desktopBridge.setAppProgress(progress)
|
|
||||||
return result?.ok ?? false
|
|
||||||
},
|
|
||||||
[electronAvailable]
|
|
||||||
)
|
|
||||||
|
|
||||||
const getElectronVersion = useCallback(async () => {
|
const getElectronVersion = useCallback(async () => {
|
||||||
if (!electronAvailable) return null
|
if (!electronAvailable) return null
|
||||||
return await desktopBridge.getAppVersion()
|
return await desktopBridge.getAppVersion()
|
||||||
@ -411,7 +402,6 @@ const ElectronProvider = ({ children }) => {
|
|||||||
setToken,
|
setToken,
|
||||||
resizeSpotlightWindow,
|
resizeSpotlightWindow,
|
||||||
setSidebarViewMenu,
|
setSidebarViewMenu,
|
||||||
setAppProgress,
|
|
||||||
getElectronVersion,
|
getElectronVersion,
|
||||||
getAppEngine,
|
getAppEngine,
|
||||||
registerTabHistoryHandler,
|
registerTabHistoryHandler,
|
||||||
|
|||||||
@ -1,240 +0,0 @@
|
|||||||
import get from 'lodash/get'
|
|
||||||
import set from 'lodash/set'
|
|
||||||
import cloneDeep from 'lodash/cloneDeep'
|
|
||||||
import { mergeFormData } from './Utils'
|
|
||||||
|
|
||||||
// Patch computed fields onto the complete object.
|
|
||||||
// This mirrors the handling used by ObjectForm.
|
|
||||||
export const applyComputedEntries = (base, entries = []) => {
|
|
||||||
const result = mergeFormData(base || {})
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const { namePath, value } = entry || {}
|
|
||||||
if (!Array.isArray(namePath) || value === undefined) return
|
|
||||||
set(result, namePath, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const idsEqual = (left, right) =>
|
|
||||||
left != null &&
|
|
||||||
right != null &&
|
|
||||||
String(left).toLowerCase() === String(right).toLowerCase()
|
|
||||||
|
|
||||||
export const getReferenceId = (value) => {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
return trimmed || null
|
|
||||||
}
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
||||||
if (typeof value._id === 'string' && value._id.trim()) return value._id
|
|
||||||
if (typeof value._id?._id === 'string' && value._id._id.trim()) {
|
|
||||||
return value._id._id
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// A reference is hydrated when it has fields besides _id.
|
|
||||||
// API object ids often arrive as { _id } until they are fetched.
|
|
||||||
const isHydratedObject = (value) => {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
||||||
return Object.keys(value).some((key) => key !== '_id')
|
|
||||||
}
|
|
||||||
|
|
||||||
const toNamePath = (name, parentPath = []) => {
|
|
||||||
if (Array.isArray(name)) return [...parentPath, ...name]
|
|
||||||
if (typeof name === 'number') return [...parentPath, name]
|
|
||||||
if (typeof name === 'string' && name.length > 0) {
|
|
||||||
return [...parentPath, ...name.split('.')]
|
|
||||||
}
|
|
||||||
return parentPath
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePropertyObjectType = (property, scopeData, parentData) => {
|
|
||||||
if (typeof property?.objectType !== 'function') {
|
|
||||||
return property?.objectType || null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return property.objectType(scopeData || {}, parentData) || null
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
`Error resolving object type for property ${property?.name}:`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const collectObjectPropertyRefs = (data, modelDefinition) => {
|
|
||||||
const refs = []
|
|
||||||
if (!data || typeof data !== 'object') return refs
|
|
||||||
|
|
||||||
const visit = (properties, parentPath, scopeData, parentData) => {
|
|
||||||
if (!Array.isArray(properties)) return
|
|
||||||
|
|
||||||
properties.forEach((property) => {
|
|
||||||
if (!property?.name) return
|
|
||||||
|
|
||||||
const propertyPath = toNamePath(property.name, parentPath)
|
|
||||||
const value = get(data, propertyPath)
|
|
||||||
|
|
||||||
if (property.type === 'object' || property.type === 'objectList') {
|
|
||||||
const objectType = resolvePropertyObjectType(
|
|
||||||
property,
|
|
||||||
scopeData,
|
|
||||||
parentData
|
|
||||||
)
|
|
||||||
if (!objectType) return
|
|
||||||
|
|
||||||
const items =
|
|
||||||
property.type === 'objectList'
|
|
||||||
? Array.isArray(value)
|
|
||||||
? value
|
|
||||||
: []
|
|
||||||
: value == null
|
|
||||||
? []
|
|
||||||
: [value]
|
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
const id = getReferenceId(item)
|
|
||||||
if (!id) return
|
|
||||||
refs.push({
|
|
||||||
namePath:
|
|
||||||
property.type === 'objectList'
|
|
||||||
? [...propertyPath, index]
|
|
||||||
: propertyPath,
|
|
||||||
id: String(id),
|
|
||||||
objectType,
|
|
||||||
hydrated: isHydratedObject(item)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!Array.isArray(property.properties) ||
|
|
||||||
property.properties.length === 0
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (property.type === 'objectChildren' && Array.isArray(value)) {
|
|
||||||
value.forEach((child, index) => {
|
|
||||||
visit(
|
|
||||||
property.properties,
|
|
||||||
[...propertyPath, index],
|
|
||||||
child,
|
|
||||||
scopeData
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
visit(property.properties, propertyPath, value, scopeData)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
visit(modelDefinition?.properties, [], data, null)
|
|
||||||
return refs
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getRelatedObjectSubscriptionKey = (data, modelDefinition) => {
|
|
||||||
const seen = new Set()
|
|
||||||
collectObjectPropertyRefs(data, modelDefinition).forEach((ref) => {
|
|
||||||
seen.add(`${ref.objectType}:${ref.id.toLowerCase()}`)
|
|
||||||
})
|
|
||||||
return Array.from(seen).sort().join('|')
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeRelatedObjectValue = (existing, id, value) => {
|
|
||||||
const base =
|
|
||||||
existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
||||||
? existing
|
|
||||||
: { _id: id }
|
|
||||||
return mergeFormData(base, value, { _id: id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const withEditingFlag = (prev, next) => {
|
|
||||||
if (prev?._isEditing != null) {
|
|
||||||
next._isEditing = prev._isEditing
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
export const applyHydratedObjects = (
|
|
||||||
prev,
|
|
||||||
refs,
|
|
||||||
fetchedByKey,
|
|
||||||
modelDefinition,
|
|
||||||
calculateComputedValues
|
|
||||||
) => {
|
|
||||||
if (!prev || refs.length === 0 || fetchedByKey.size === 0) return null
|
|
||||||
|
|
||||||
const draft = cloneDeep(prev)
|
|
||||||
const applied = []
|
|
||||||
|
|
||||||
refs.forEach((ref) => {
|
|
||||||
const fullObject = fetchedByKey.get(
|
|
||||||
`${ref.objectType}:${ref.id.toLowerCase()}`
|
|
||||||
)
|
|
||||||
if (!fullObject) return
|
|
||||||
|
|
||||||
const current = get(draft, ref.namePath)
|
|
||||||
const currentId = getReferenceId(current)
|
|
||||||
if (!idsEqual(currentId, ref.id) || isHydratedObject(current)) return
|
|
||||||
|
|
||||||
set(
|
|
||||||
draft,
|
|
||||||
ref.namePath,
|
|
||||||
mergeRelatedObjectValue(current, ref.id, fullObject)
|
|
||||||
)
|
|
||||||
applied.push(ref)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (applied.length === 0) return null
|
|
||||||
|
|
||||||
const computedEntries = calculateComputedValues(draft, modelDefinition)
|
|
||||||
const next = withEditingFlag(
|
|
||||||
prev,
|
|
||||||
applyComputedEntries(draft, computedEntries)
|
|
||||||
)
|
|
||||||
return { next, refs: applied, computedEntries }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const applyRelatedObjectUpdates = (
|
|
||||||
prev,
|
|
||||||
objectType,
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
modelDefinition,
|
|
||||||
calculateComputedValues
|
|
||||||
) => {
|
|
||||||
if (!prev || !objectType || id == null || value == null) return null
|
|
||||||
|
|
||||||
const normalizedId = String(id).toLowerCase()
|
|
||||||
const refs = collectObjectPropertyRefs(prev, modelDefinition).filter(
|
|
||||||
(ref) =>
|
|
||||||
ref.objectType === objectType && ref.id.toLowerCase() === normalizedId
|
|
||||||
)
|
|
||||||
if (refs.length === 0) return null
|
|
||||||
|
|
||||||
const draft = cloneDeep(prev)
|
|
||||||
refs.forEach((ref) => {
|
|
||||||
set(
|
|
||||||
draft,
|
|
||||||
ref.namePath,
|
|
||||||
mergeRelatedObjectValue(get(draft, ref.namePath), ref.id, value)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const computedEntries = calculateComputedValues(draft, modelDefinition)
|
|
||||||
const next = withEditingFlag(
|
|
||||||
prev,
|
|
||||||
applyComputedEntries(draft, computedEntries)
|
|
||||||
)
|
|
||||||
return { next, refs, computedEntries }
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
const config = {
|
const config = {
|
||||||
development: {
|
development: {
|
||||||
backendUrl: 'https://dev.tombutcher.work/fcapi',
|
backendUrl: 'https://dev.tombutcher.work/api',
|
||||||
webAppUrl: 'https://dev.tombutcher.work',
|
webAppUrl: 'https://dev.tombutcher.work',
|
||||||
printServerUrl: 'ws://192.168.68.53:8081',
|
printServerUrl: 'ws://192.168.68.53:8081',
|
||||||
apiServerUrl: 'https://dev-wss.tombutcher.work',
|
apiServerUrl: 'https://dev-wss.tombutcher.work',
|
||||||
|
|||||||
@ -107,7 +107,7 @@ export const AuditLog = {
|
|||||||
{
|
{
|
||||||
name: 'operation',
|
name: 'operation',
|
||||||
label: 'Operation',
|
label: 'Operation',
|
||||||
columnWidth: 150,
|
columnWidth: 120,
|
||||||
type: 'operation'
|
type: 'operation'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -387,8 +387,7 @@ export const EmailTemplate = {
|
|||||||
{ value: 'pdf', label: 'PDF' },
|
{ value: 'pdf', label: 'PDF' },
|
||||||
{ value: 'jpeg', label: 'JPEG' },
|
{ value: 'jpeg', label: 'JPEG' },
|
||||||
{ value: 'png', label: 'PNG' },
|
{ value: 'png', label: 'PNG' },
|
||||||
{ value: 'svg', label: 'SVG' },
|
{ value: 'svg', label: 'SVG' }
|
||||||
{ value: 'docx', label: 'Word' }
|
|
||||||
],
|
],
|
||||||
value: (row) => {
|
value: (row) => {
|
||||||
if (row?.type === 'documentTemplate') {
|
if (row?.type === 'documentTemplate') {
|
||||||
|
|||||||
@ -150,7 +150,6 @@ export const Job = {
|
|||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
'_reference',
|
'_reference',
|
||||||
'name',
|
|
||||||
'quantity',
|
'quantity',
|
||||||
'state',
|
'state',
|
||||||
'gcodeFile',
|
'gcodeFile',
|
||||||
@ -160,7 +159,6 @@ export const Job = {
|
|||||||
'updatedAt'
|
'updatedAt'
|
||||||
],
|
],
|
||||||
filters: [
|
filters: [
|
||||||
'name',
|
|
||||||
'state',
|
'state',
|
||||||
'gcodeFile',
|
'gcodeFile',
|
||||||
'quantity',
|
'quantity',
|
||||||
@ -172,7 +170,6 @@ export const Job = {
|
|||||||
'finishedAt'
|
'finishedAt'
|
||||||
],
|
],
|
||||||
sorters: [
|
sorters: [
|
||||||
'name',
|
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'state',
|
'state',
|
||||||
'quantity',
|
'quantity',
|
||||||
@ -215,21 +212,6 @@ export const Job = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
columnWidth: 200
|
columnWidth: 200
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
label: 'Name',
|
|
||||||
type: 'text',
|
|
||||||
readOnly: true,
|
|
||||||
columnFixed: 'left',
|
|
||||||
columnWidth: 240
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'startedAt',
|
|
||||||
label: 'Started At',
|
|
||||||
type: 'dateTime',
|
|
||||||
readOnly: true,
|
|
||||||
columnWidth: 175
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'state',
|
name: 'state',
|
||||||
label: 'State',
|
label: 'State',
|
||||||
@ -242,6 +224,21 @@ export const Job = {
|
|||||||
columnWidth: 260,
|
columnWidth: 260,
|
||||||
readOnly: true
|
readOnly: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'startedAt',
|
||||||
|
label: 'Started At',
|
||||||
|
type: 'dateTime',
|
||||||
|
readOnly: true,
|
||||||
|
columnWidth: 175
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'quantity',
|
||||||
|
label: 'Quantity',
|
||||||
|
type: 'number',
|
||||||
|
columnFixed: 'left',
|
||||||
|
columnWidth: 125,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'finishedAt',
|
name: 'finishedAt',
|
||||||
label: 'Finished At',
|
label: 'Finished At',
|
||||||
@ -251,12 +248,13 @@ export const Job = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'quantity',
|
name: 'gcodeFile',
|
||||||
label: 'Quantity',
|
label: 'GCode File',
|
||||||
type: 'number',
|
type: 'object',
|
||||||
columnFixed: 'left',
|
columnWidth: 200,
|
||||||
columnWidth: 125,
|
objectType: 'gcodeFile',
|
||||||
required: true
|
required: true,
|
||||||
|
showHyperlink: true
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -287,15 +285,6 @@ export const Job = {
|
|||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'gcodeFile',
|
|
||||||
label: 'GCode File',
|
|
||||||
type: 'object',
|
|
||||||
columnWidth: 200,
|
|
||||||
objectType: 'gcodeFile',
|
|
||||||
required: true,
|
|
||||||
showHyperlink: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'printers',
|
name: 'printers',
|
||||||
label: 'Printers',
|
label: 'Printers',
|
||||||
|
|||||||
@ -3,9 +3,6 @@ import { createElement, lazy } from 'react'
|
|||||||
const SubJobInfo = lazy(
|
const SubJobInfo = lazy(
|
||||||
() => import('../../components/Dashboard/Production/SubJobs/SubJobInfo')
|
() => import('../../components/Dashboard/Production/SubJobs/SubJobInfo')
|
||||||
)
|
)
|
||||||
const CancelSubJob = lazy(
|
|
||||||
() => import('../../components/Dashboard/Production/SubJobs/CancelSubJob')
|
|
||||||
)
|
|
||||||
import SubJobIcon from '../../components/Icons/SubJobIcon'
|
import SubJobIcon from '../../components/Icons/SubJobIcon'
|
||||||
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
|
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
|
||||||
import XMarkIcon from '../../components/Icons/XMarkIcon'
|
import XMarkIcon from '../../components/Icons/XMarkIcon'
|
||||||
@ -38,18 +35,13 @@ export const SubJob = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cancel',
|
name: 'cancel',
|
||||||
type: 'modal',
|
type: 'page',
|
||||||
modalWidth: 520,
|
pageName: 'info',
|
||||||
modalCentered: true,
|
|
||||||
label: 'Cancel Sub Job',
|
label: 'Cancel Sub Job',
|
||||||
row: true,
|
row: true,
|
||||||
danger: true,
|
|
||||||
icon: XMarkIcon,
|
icon: XMarkIcon,
|
||||||
disabled: (objectData) => {
|
disabled: (objectData) => {
|
||||||
return objectData?.state?.type !== 'queued'
|
return objectData?.state?.type !== 'queued'
|
||||||
},
|
|
||||||
content: (objectData, { onOk } = {}) => {
|
|
||||||
return createElement(CancelSubJob, { objectData, onOk })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -61,7 +53,6 @@ export const SubJob = {
|
|||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
'_reference',
|
'_reference',
|
||||||
'name',
|
|
||||||
'printer',
|
'printer',
|
||||||
'job',
|
'job',
|
||||||
'state',
|
'state',
|
||||||
@ -71,7 +62,6 @@ export const SubJob = {
|
|||||||
'updatedAt'
|
'updatedAt'
|
||||||
],
|
],
|
||||||
filters: [
|
filters: [
|
||||||
'name',
|
|
||||||
'_id',
|
'_id',
|
||||||
'job',
|
'job',
|
||||||
'printer',
|
'printer',
|
||||||
@ -83,7 +73,6 @@ export const SubJob = {
|
|||||||
'finishedAt'
|
'finishedAt'
|
||||||
],
|
],
|
||||||
sorters: [
|
sorters: [
|
||||||
'name',
|
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'state',
|
'state',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
@ -127,21 +116,6 @@ export const SubJob = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
columnWidth: 200
|
columnWidth: 200
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
label: 'Name',
|
|
||||||
type: 'text',
|
|
||||||
readOnly: true,
|
|
||||||
columnFixed: 'left',
|
|
||||||
columnWidth: 220
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'startedAt',
|
|
||||||
label: 'Started At',
|
|
||||||
type: 'dateTime',
|
|
||||||
readOnly: true,
|
|
||||||
columnWidth: 175
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'state',
|
name: 'state',
|
||||||
label: 'State',
|
label: 'State',
|
||||||
@ -154,10 +128,9 @@ export const SubJob = {
|
|||||||
columnWidth: 260,
|
columnWidth: 260,
|
||||||
readOnly: true
|
readOnly: true
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'finishedAt',
|
name: 'startedAt',
|
||||||
label: 'Finished At',
|
label: 'Started At',
|
||||||
type: 'dateTime',
|
type: 'dateTime',
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
columnWidth: 175
|
columnWidth: 175
|
||||||
@ -171,6 +144,32 @@ export const SubJob = {
|
|||||||
showCopy: true
|
showCopy: true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'finishedAt',
|
||||||
|
label: 'Finished At',
|
||||||
|
type: 'dateTime',
|
||||||
|
readOnly: true,
|
||||||
|
columnWidth: 175
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'job',
|
||||||
|
label: 'Job',
|
||||||
|
type: 'object',
|
||||||
|
objectType: 'job',
|
||||||
|
showHyperlink: true,
|
||||||
|
columnWidth: 200
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'printer',
|
||||||
|
label: 'Printer',
|
||||||
|
type: 'object',
|
||||||
|
columnFixed: 'left',
|
||||||
|
objectType: 'printer',
|
||||||
|
showHyperlink: true,
|
||||||
|
columnWidth: 200
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'totalTime',
|
name: 'totalTime',
|
||||||
label: 'Total Time',
|
label: 'Total Time',
|
||||||
@ -198,25 +197,6 @@ export const SubJob = {
|
|||||||
|
|
||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'job',
|
|
||||||
label: 'Job',
|
|
||||||
type: 'object',
|
|
||||||
objectType: 'job',
|
|
||||||
showHyperlink: true,
|
|
||||||
columnWidth: 200
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'printer',
|
|
||||||
label: 'Printer',
|
|
||||||
type: 'object',
|
|
||||||
columnFixed: 'left',
|
|
||||||
objectType: 'printer',
|
|
||||||
showHyperlink: true,
|
|
||||||
columnWidth: 200
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
import { setMacDockProgress } from './macos-window-effects.js'
|
|
||||||
import {
|
|
||||||
setWindowsTaskbarProgress,
|
|
||||||
TASKBAR_STATE
|
|
||||||
} from './windows-taskbar-progress.js'
|
|
||||||
|
|
||||||
const MAC_DOCK_STATE = { normal: 0, error: 1, paused: 2 }
|
|
||||||
|
|
||||||
const reports = new Map()
|
|
||||||
|
|
||||||
function progressState(status) {
|
|
||||||
if (status === 'error') return 'error'
|
|
||||||
if (status === 'warning') return 'paused'
|
|
||||||
return 'normal'
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProgress({ percent, status } = {}) {
|
|
||||||
const value = Number(percent)
|
|
||||||
if (percent == null || !Number.isFinite(value)) return null
|
|
||||||
return {
|
|
||||||
fraction: Math.min(100, Math.max(0, value)) / 100,
|
|
||||||
state: progressState(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDockProgress() {
|
|
||||||
let best = null
|
|
||||||
for (const report of reports.values()) {
|
|
||||||
if (report.progress && (!best || report.progress.fraction > best.fraction)) {
|
|
||||||
best = report.progress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMacDockProgress(
|
|
||||||
best ? best.fraction : null,
|
|
||||||
best ? MAC_DOCK_STATE[best.state] : 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setAppProgress(windowId, window, progress) {
|
|
||||||
const normalized = normalizeProgress(progress)
|
|
||||||
|
|
||||||
if (normalized) {
|
|
||||||
reports.set(windowId, { window, progress: normalized })
|
|
||||||
} else {
|
|
||||||
reports.delete(windowId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
applyDockProgress()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return setWindowsTaskbarProgress(
|
|
||||||
window?.ptr,
|
|
||||||
normalized?.fraction ?? null,
|
|
||||||
TASKBAR_STATE[normalized?.state ?? 'normal']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearAppProgress(windowId) {
|
|
||||||
if (!reports.delete(windowId)) return
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
applyDockProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -45,17 +45,7 @@ function resolveDylibPath() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
let loadedLibrary
|
|
||||||
|
|
||||||
function loadMacWindowEffectsLibrary() {
|
function loadMacWindowEffectsLibrary() {
|
||||||
if (loadedLibrary !== undefined) {
|
|
||||||
return loadedLibrary
|
|
||||||
}
|
|
||||||
loadedLibrary = openMacWindowEffectsLibrary()
|
|
||||||
return loadedLibrary
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMacWindowEffectsLibrary() {
|
|
||||||
const dylibPath = resolveDylibPath()
|
const dylibPath = resolveDylibPath()
|
||||||
if (!dylibPath) {
|
if (!dylibPath) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@ -87,10 +77,6 @@ function openMacWindowEffectsLibrary() {
|
|||||||
setNativeWindowDragRegion: {
|
setNativeWindowDragRegion: {
|
||||||
args: [FFIType.ptr, FFIType.f64, FFIType.f64],
|
args: [FFIType.ptr, FFIType.f64, FFIType.f64],
|
||||||
returns: FFIType.bool
|
returns: FFIType.bool
|
||||||
},
|
|
||||||
setDockProgress: {
|
|
||||||
args: [FFIType.f64, FFIType.i32],
|
|
||||||
returns: FFIType.bool
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -118,28 +104,6 @@ function applyTrafficLightsPosition(lib, mainWindow, attempt = 0) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// fraction is 0..1, or null to remove the bar; state: 0 normal, 1 error, 2 paused.
|
|
||||||
export function setMacDockProgress(fraction, state = 0) {
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const loaded = loadMacWindowEffectsLibrary()
|
|
||||||
if (!loaded?.lib.symbols.setDockProgress) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return loaded.lib.symbols.setDockProgress(
|
|
||||||
fraction == null ? -1 : fraction,
|
|
||||||
state
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('macOS dock progress: failed to update dock tile:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyMacOSWindowEffects(mainWindow) {
|
export function applyMacOSWindowEffects(mainWindow) {
|
||||||
if (process.platform !== 'darwin' || !mainWindow?.ptr) {
|
if (process.platform !== 'darwin' || !mainWindow?.ptr) {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { BrowserView } from 'electrobun/bun'
|
import { BrowserView } from 'electrobun/bun'
|
||||||
import { setAppProgress } from './app-progress.js'
|
|
||||||
import {
|
import {
|
||||||
checkForCompletedUpdate,
|
checkForCompletedUpdate,
|
||||||
getRunningAppVersion,
|
getRunningAppVersion,
|
||||||
@ -21,9 +20,7 @@ import {
|
|||||||
} from './store.js'
|
} from './store.js'
|
||||||
import {
|
import {
|
||||||
createAppWindow,
|
createAppWindow,
|
||||||
getFocusedWindowId,
|
|
||||||
getMainWindow,
|
getMainWindow,
|
||||||
getWindowById,
|
|
||||||
getWindowSession,
|
getWindowSession,
|
||||||
getWindowState,
|
getWindowState,
|
||||||
handleWindowControl,
|
handleWindowControl,
|
||||||
@ -106,15 +103,6 @@ export function createAppRpc() {
|
|||||||
setSidebarViewMenu: async ({ sections }) => ({
|
setSidebarViewMenu: async ({ sections }) => ({
|
||||||
ok: setSidebarViewMenu(sections)
|
ok: setSidebarViewMenu(sections)
|
||||||
}),
|
}),
|
||||||
setAppProgress: async ({ windowId, percent, status } = {}) => {
|
|
||||||
const targetWindowId = windowId || getFocusedWindowId()
|
|
||||||
return {
|
|
||||||
ok: setAppProgress(targetWindowId, getWindowById(targetWindowId), {
|
|
||||||
percent,
|
|
||||||
status
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
checkAppUpdateResult: async () =>
|
checkAppUpdateResult: async () =>
|
||||||
checkForCompletedUpdate(getMainWindow()),
|
checkForCompletedUpdate(getMainWindow()),
|
||||||
checkDuplicateInstallations: async () => checkForDuplicateInstallations(),
|
checkDuplicateInstallations: async () => checkForDuplicateInstallations(),
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import Electrobun, { BrowserWindow, Updater, Utils } from 'electrobun/bun'
|
import Electrobun, { BrowserWindow, Updater, Utils } from 'electrobun/bun'
|
||||||
import { clearAppProgress } from './app-progress.js'
|
|
||||||
import { applyApplicationMenu, setupApplicationMenuEvents } from './menu.js'
|
import { applyApplicationMenu, setupApplicationMenuEvents } from './menu.js'
|
||||||
import {
|
import {
|
||||||
applyMacOSWindowEffects,
|
applyMacOSWindowEffects,
|
||||||
@ -448,7 +447,6 @@ function setupWindowEvents(window, windowId) {
|
|||||||
})
|
})
|
||||||
window.on?.('close', () => {
|
window.on?.('close', () => {
|
||||||
captureWindowLayout(windowId)
|
captureWindowLayout(windowId)
|
||||||
clearAppProgress(windowId)
|
|
||||||
windows.delete(windowId)
|
windows.delete(windowId)
|
||||||
if (focusedWindowId === windowId) {
|
if (focusedWindowId === windowId) {
|
||||||
focusedWindowId = getFocusedWindowId()
|
focusedWindowId = getFocusedWindowId()
|
||||||
|
|||||||
@ -1,142 +0,0 @@
|
|||||||
import { CFunction, dlopen, FFIType, ptr, read } from 'bun:ffi'
|
|
||||||
|
|
||||||
const CLSID_TASKBAR_LIST = '56FDF344-FD6D-11D0-958A-006097C9A090'
|
|
||||||
const IID_ITASKBAR_LIST3 = 'EA1AFB91-9E28-4B86-90E9-9E9F8A5EEDAF'
|
|
||||||
const COINIT_APARTMENTTHREADED = 0x2
|
|
||||||
const CLSCTX_INPROC_SERVER = 0x1
|
|
||||||
const RPC_E_CHANGED_MODE = 0x80010106 | 0
|
|
||||||
|
|
||||||
// ITaskbarList3 vtable slots (IUnknown 0-2, ITaskbarList 3-7, ITaskbarList2 8).
|
|
||||||
const VTABLE_HR_INIT = 3
|
|
||||||
const VTABLE_SET_PROGRESS_VALUE = 9
|
|
||||||
const VTABLE_SET_PROGRESS_STATE = 10
|
|
||||||
|
|
||||||
export const TASKBAR_STATE = {
|
|
||||||
none: 0x0,
|
|
||||||
indeterminate: 0x1,
|
|
||||||
normal: 0x2,
|
|
||||||
error: 0x4,
|
|
||||||
paused: 0x8
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROGRESS_TOTAL = 1000n
|
|
||||||
|
|
||||||
let taskbar
|
|
||||||
|
|
||||||
function guidBuffer(guid) {
|
|
||||||
const hex = guid.replace(/-/g, '')
|
|
||||||
const buffer = Buffer.alloc(16)
|
|
||||||
buffer.writeUInt32LE(parseInt(hex.slice(0, 8), 16), 0)
|
|
||||||
buffer.writeUInt16LE(parseInt(hex.slice(8, 12), 16), 4)
|
|
||||||
buffer.writeUInt16LE(parseInt(hex.slice(12, 16), 16), 6)
|
|
||||||
Buffer.from(hex.slice(16), 'hex').copy(buffer, 8)
|
|
||||||
return buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
function vtableMethod(object, slot, args) {
|
|
||||||
const vtable = read.ptr(object, 0)
|
|
||||||
return CFunction({
|
|
||||||
ptr: read.ptr(vtable, slot * 8),
|
|
||||||
args: [FFIType.ptr, ...args],
|
|
||||||
returns: FFIType.i32
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTaskbarList() {
|
|
||||||
const ole32 = dlopen('ole32.dll', {
|
|
||||||
CoInitializeEx: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
|
||||||
CoCreateInstance: {
|
|
||||||
args: [FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr],
|
|
||||||
returns: FFIType.i32
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const initResult = ole32.symbols.CoInitializeEx(null, COINIT_APARTMENTTHREADED)
|
|
||||||
if (initResult < 0 && initResult !== RPC_E_CHANGED_MODE) {
|
|
||||||
throw new Error(`CoInitializeEx failed (0x${(initResult >>> 0).toString(16)})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clsid = guidBuffer(CLSID_TASKBAR_LIST)
|
|
||||||
const iid = guidBuffer(IID_ITASKBAR_LIST3)
|
|
||||||
const out = new BigUint64Array(1)
|
|
||||||
const createResult = ole32.symbols.CoCreateInstance(
|
|
||||||
ptr(clsid),
|
|
||||||
null,
|
|
||||||
CLSCTX_INPROC_SERVER,
|
|
||||||
ptr(iid),
|
|
||||||
ptr(out)
|
|
||||||
)
|
|
||||||
if (createResult < 0 || !out[0]) {
|
|
||||||
throw new Error(
|
|
||||||
`CoCreateInstance(TaskbarList) failed (0x${(createResult >>> 0).toString(16)})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const object = Number(out[0])
|
|
||||||
const hrInit = vtableMethod(object, VTABLE_HR_INIT, [])
|
|
||||||
const initTaskbarResult = hrInit(object)
|
|
||||||
if (initTaskbarResult < 0) {
|
|
||||||
throw new Error(
|
|
||||||
`ITaskbarList3.HrInit failed (0x${(initTaskbarResult >>> 0).toString(16)})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
object,
|
|
||||||
setProgressValue: vtableMethod(object, VTABLE_SET_PROGRESS_VALUE, [
|
|
||||||
FFIType.ptr,
|
|
||||||
FFIType.u64,
|
|
||||||
FFIType.u64
|
|
||||||
]),
|
|
||||||
setProgressState: vtableMethod(object, VTABLE_SET_PROGRESS_STATE, [
|
|
||||||
FFIType.ptr,
|
|
||||||
FFIType.u32
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTaskbarList() {
|
|
||||||
if (taskbar !== undefined) {
|
|
||||||
return taskbar
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
taskbar = createTaskbarList()
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Windows taskbar progress unavailable:', error)
|
|
||||||
taskbar = null
|
|
||||||
}
|
|
||||||
return taskbar
|
|
||||||
}
|
|
||||||
|
|
||||||
// fraction is 0..1, or null to remove the bar.
|
|
||||||
export function setWindowsTaskbarProgress(
|
|
||||||
hwnd,
|
|
||||||
fraction,
|
|
||||||
state = TASKBAR_STATE.normal
|
|
||||||
) {
|
|
||||||
if (process.platform !== 'win32' || !hwnd) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = getTaskbarList()
|
|
||||||
if (!list) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fraction == null) {
|
|
||||||
return list.setProgressState(list.object, hwnd, TASKBAR_STATE.none) >= 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const completed = BigInt(
|
|
||||||
Math.round(Math.min(1, Math.max(0, fraction)) * Number(PROGRESS_TOTAL))
|
|
||||||
)
|
|
||||||
// SetProgressValue resets the state to normal, so the state goes second.
|
|
||||||
list.setProgressValue(list.object, hwnd, completed, PROGRESS_TOTAL)
|
|
||||||
return list.setProgressState(list.object, hwnd, state) >= 0
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Windows taskbar progress: failed to update:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -263,7 +263,6 @@ const electronAPI = {
|
|||||||
invokeRequest('resizeSpotlightWindow', { height }),
|
invokeRequest('resizeSpotlightWindow', { height }),
|
||||||
setSidebarViewMenu: (sections) =>
|
setSidebarViewMenu: (sections) =>
|
||||||
invokeRequest('setSidebarViewMenu', { sections }),
|
invokeRequest('setSidebarViewMenu', { sections }),
|
||||||
setAppProgress: (progress) => invokeRequest('setAppProgress', progress),
|
|
||||||
getAppVersion: () => invokeRequest('getAppVersion'),
|
getAppVersion: () => invokeRequest('getAppVersion'),
|
||||||
getAppEngine: () => invokeRequest('getAppEngine'),
|
getAppEngine: () => invokeRequest('getAppEngine'),
|
||||||
syncWindowTabs: (payload) => invokeRequest('syncWindowTabs', payload),
|
syncWindowTabs: (payload) => invokeRequest('syncWindowTabs', payload),
|
||||||
|
|||||||
@ -7,7 +7,6 @@ const COOKIE_OPTIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AUTH_STORAGE_KEY = 'farmcontrol.auth'
|
const AUTH_STORAGE_KEY = 'farmcontrol.auth'
|
||||||
const SLICER_AUTH_STORAGE_KEY = 'farmcontrol.slicerAuth'
|
|
||||||
const LEGACY_AUTH_COOKIE_NAMES = ['authToken', 'authExpiresAt', 'user']
|
const LEGACY_AUTH_COOKIE_NAMES = ['authToken', 'authExpiresAt', 'user']
|
||||||
|
|
||||||
const getAuthExpiryTime = (expiresAt) => {
|
const getAuthExpiryTime = (expiresAt) => {
|
||||||
@ -326,63 +325,3 @@ export const clearAuthCookies = () => {
|
|||||||
}
|
}
|
||||||
clearLegacyAuthCookies()
|
clearLegacyAuthCookies()
|
||||||
}
|
}
|
||||||
|
|
||||||
const readStoredSession = (storageKey) => {
|
|
||||||
const raw = window.localStorage.getItem(storageKey)
|
|
||||||
if (!raw) return null
|
|
||||||
const session = parseStoredSession(raw)
|
|
||||||
const expirationTime = getAuthExpiryTime(session.expiresAt)
|
|
||||||
if (
|
|
||||||
!session.token ||
|
|
||||||
!Number.isFinite(expirationTime) ||
|
|
||||||
expirationTime <= Date.now()
|
|
||||||
) {
|
|
||||||
window.localStorage.removeItem(storageKey)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Slicer app-password sessions are stored apart from the normal login so a
|
|
||||||
* short-lived FarmControl session cannot replace or expire them.
|
|
||||||
*/
|
|
||||||
export const getSlicerAuthSession = () => {
|
|
||||||
try {
|
|
||||||
return readStoredSession(SLICER_AUTH_STORAGE_KEY)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading slicer auth session:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setSlicerAuthSession = (authData) => {
|
|
||||||
try {
|
|
||||||
if (!authData?.access_token || !authData?.expires_at) {
|
|
||||||
console.warn('Auth data is required')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = getSlicerAuthSession()
|
|
||||||
const serialized = serializeAuthSession(authData, existing?.user)
|
|
||||||
if (window.localStorage.getItem(SLICER_AUTH_STORAGE_KEY) === serialized) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.setItem(SLICER_AUTH_STORAGE_KEY, serialized)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error setting slicer auth session:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clearSlicerAuthSession = () => {
|
|
||||||
try {
|
|
||||||
window.localStorage.removeItem(SLICER_AUTH_STORAGE_KEY)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing slicer auth session:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,99 +0,0 @@
|
|||||||
function bytesOf(value) {
|
|
||||||
if (value == null) return null
|
|
||||||
if (value instanceof Uint8Array) return value.byteLength === 12 ? value : null
|
|
||||||
if (typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(value)
|
|
||||||
if (keys.length !== 12) return null
|
|
||||||
const bytes = new Uint8Array(12)
|
|
||||||
for (let index = 0; index < 12; index += 1) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(value, String(index)) &&
|
|
||||||
!Object.prototype.hasOwnProperty.call(value, index)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const byte = Number(value[index])
|
|
||||||
if (!Number.isInteger(byte) || byte < 0 || byte > 255) return null
|
|
||||||
bytes[index] = byte
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHex(bytes) {
|
|
||||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const PURE_REF_KEYS = new Set(['_id', 'id', 'buffer', '$oid'])
|
|
||||||
|
|
||||||
export function refId(value) {
|
|
||||||
if (value == null || value === '') return null
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
if (typeof value === 'object' && typeof value.toHexString === 'function') {
|
|
||||||
return value.toHexString()
|
|
||||||
}
|
|
||||||
if (typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (typeof value.$oid === 'string') return refId(value.$oid)
|
|
||||||
|
|
||||||
const keys = Object.keys(value)
|
|
||||||
if (
|
|
||||||
keys.length > 0 &&
|
|
||||||
keys.every((key) => key === 'buffer' || key === 'id' || key === 'type' || key === 'data')
|
|
||||||
) {
|
|
||||||
const bytes = bytesOf(value.buffer) || bytesOf(value.id) || bytesOf(value)
|
|
||||||
if (bytes) return toHex(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value._id != null) return refId(value._id)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPureRef(value) {
|
|
||||||
if (value == null || typeof value !== 'object') return false
|
|
||||||
if (typeof value.toHexString === 'function') return true
|
|
||||||
if (Array.isArray(value) || value instanceof Date) return false
|
|
||||||
const keys = Object.keys(value)
|
|
||||||
return keys.length > 0 && keys.every((key) => PURE_REF_KEYS.has(key)) && refId(value) != null
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBinaryPayload(value) {
|
|
||||||
if (value == null || typeof value !== 'object') return false
|
|
||||||
if (value instanceof Uint8Array || value instanceof ArrayBuffer) return true
|
|
||||||
const name = value.constructor?.name
|
|
||||||
return name === 'Blob' || name === 'File'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stringifyIds(value) {
|
|
||||||
if (value == null) return value
|
|
||||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (typeof value.toHexString === 'function') return value.toHexString()
|
|
||||||
if (value instanceof Date || typeof value === 'function') return value
|
|
||||||
if (isBinaryPayload(value)) return value
|
|
||||||
if (Array.isArray(value)) return value.map((item) => stringifyIds(item))
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
if (isPureRef(value)) {
|
|
||||||
const id = refId(value)
|
|
||||||
if (typeof id === 'string') return id
|
|
||||||
}
|
|
||||||
const copy = {}
|
|
||||||
for (const [key, child] of Object.entries(value)) {
|
|
||||||
copy[key] = stringifyIds(child)
|
|
||||||
}
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bindStringIds(socket) {
|
|
||||||
const emit = socket.emit.bind(socket)
|
|
||||||
socket.emit = (event, ...args) =>
|
|
||||||
emit(
|
|
||||||
event,
|
|
||||||
...args.map((arg) => (typeof arg === 'function' ? arg : stringifyIds(arg)))
|
|
||||||
)
|
|
||||||
return socket
|
|
||||||
}
|
|
||||||
@ -50,29 +50,12 @@ export default defineConfig({
|
|||||||
three: path.resolve(__dirname, 'node_modules/three'),
|
three: path.resolve(__dirname, 'node_modules/three'),
|
||||||
...codemirrorSingletons
|
...codemirrorSingletons
|
||||||
},
|
},
|
||||||
// Deep `antd/es/*` imports are optimized separately from the app entry.
|
dedupe: Object.keys(codemirrorSingletons)
|
||||||
// Without dedupe they get their own React chunk, and hooks throw
|
|
||||||
// "Cannot read properties of null (reading 'useContext')".
|
|
||||||
dedupe: ['react', 'react-dom', ...Object.keys(codemirrorSingletons)]
|
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
// Do not prebundle CodeMirror — pnpm + esbuild duplicates @codemirror/state
|
// Do not prebundle CodeMirror — pnpm + esbuild duplicates @codemirror/state
|
||||||
// and breaks FacetProvider instanceof checks.
|
// and breaks FacetProvider instanceof checks.
|
||||||
// Include antd's internal entry points up front so they share the app's
|
include: ['@ant-design/icons'],
|
||||||
// React chunk instead of being discovered later and bundled apart.
|
|
||||||
include: [
|
|
||||||
'react',
|
|
||||||
'react-dom',
|
|
||||||
'react-dom/client',
|
|
||||||
'react/jsx-dev-runtime',
|
|
||||||
'antd',
|
|
||||||
'antd/es/config-provider/hooks/useCSSVarCls',
|
|
||||||
'antd/es/config-provider/hooks/useSize',
|
|
||||||
'antd/es/input/style',
|
|
||||||
'antd/es/space/Compact',
|
|
||||||
'antd/es/layout/layout',
|
|
||||||
'@ant-design/icons'
|
|
||||||
],
|
|
||||||
exclude: [
|
exclude: [
|
||||||
'@uiw/react-codemirror',
|
'@uiw/react-codemirror',
|
||||||
'codemirror',
|
'codemirror',
|
||||||
@ -161,6 +144,11 @@ export default defineConfig({
|
|||||||
return 'three'
|
return 'three'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GCode Preview
|
||||||
|
if (id.includes('node_modules/gcode-preview')) {
|
||||||
|
return 'gcode-preview'
|
||||||
|
}
|
||||||
|
|
||||||
// --- Online 3D Viewer
|
// --- Online 3D Viewer
|
||||||
if (id.includes('node_modules/online-3d-viewer')) {
|
if (id.includes('node_modules/online-3d-viewer')) {
|
||||||
return 'online-3d-viewer'
|
return 'online-3d-viewer'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user