Fix multimaterial dialog
This commit is contained in:
parent
18c1e6e12b
commit
8f51167d5a
9 changed files with 169 additions and 14 deletions
|
|
@ -26,6 +26,9 @@ export function App({initialState, statePersister, fs}: {initialState: State, st
|
||||||
} else if (event.key === 'F6') {
|
} else if (event.key === 'F6') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
model.render({isPreview: false, now: true})
|
model.render({isPreview: false, now: true})
|
||||||
|
} else if (event.key === 'F7') {
|
||||||
|
event.preventDefault();
|
||||||
|
model.export();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext, useState } from 'react';
|
||||||
import { ModelContext } from './contexts';
|
import { ModelContext } from './contexts';
|
||||||
|
|
||||||
import { SplitButton } from 'primereact/splitbutton';
|
import { SplitButton } from 'primereact/splitbutton';
|
||||||
import { MenuItem } from 'primereact/menuitem';
|
import { MenuItem } from 'primereact/menuitem';
|
||||||
|
|
||||||
type ExtendedMenuItem = MenuItem & { buttonLabel: string };
|
type ExtendedMenuItem = MenuItem & { buttonLabel?: string };
|
||||||
|
|
||||||
export default function ExportButton({className, style}: {className?: string, style?: React.CSSProperties}) {
|
export default function ExportButton({className, style}: {className?: string, style?: React.CSSProperties}) {
|
||||||
const model = useContext(ModelContext);
|
const model = useContext(ModelContext);
|
||||||
|
|
@ -56,6 +56,14 @@ export default function ExportButton({className, style}: {className?: string, st
|
||||||
icon: 'pi pi-file',
|
icon: 'pi pi-file',
|
||||||
command: () => model!.setFormats(undefined, '3mf'),
|
command: () => model!.setFormats(undefined, '3mf'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit materials' + ((state.params.extruderColors ?? []).length > 0 ? ` (${(state.params.extruderColors ?? []).length})` : ''),
|
||||||
|
icon: 'pi pi-cog',
|
||||||
|
command: () => model!.mutate(s => s.view.extruderPickerVisibility = 'editing'),
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const exportFormat = state.is2D ? state.params.exportFormat2D : state.params.exportFormat3D;
|
const exportFormat = state.is2D ? state.params.exportFormat2D : state.params.exportFormat3D;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Toast } from 'primereact/toast';
|
||||||
import HelpMenu from './HelpMenu';
|
import HelpMenu from './HelpMenu';
|
||||||
import ExportButton from './ExportButton';
|
import ExportButton from './ExportButton';
|
||||||
import SettingsMenu from './SettingsMenu';
|
import SettingsMenu from './SettingsMenu';
|
||||||
|
import MultimaterialColorsDialog from './MultimaterialColorsDialog';
|
||||||
|
|
||||||
|
|
||||||
export default function Footer({style}: {style?: CSSProperties}) {
|
export default function Footer({style}: {style?: CSSProperties}) {
|
||||||
|
|
@ -19,7 +20,6 @@ export default function Footer({style}: {style?: CSSProperties}) {
|
||||||
|
|
||||||
const toast = useRef<Toast>(null);
|
const toast = useRef<Toast>(null);
|
||||||
|
|
||||||
|
|
||||||
const severityByMarkerSeverity = new Map<monaco.MarkerSeverity, 'danger' | 'warning' | 'info'>([
|
const severityByMarkerSeverity = new Map<monaco.MarkerSeverity, 'danger' | 'warning' | 'info'>([
|
||||||
[monaco.MarkerSeverity.Error, 'danger'],
|
[monaco.MarkerSeverity.Error, 'danger'],
|
||||||
[monaco.MarkerSeverity.Warning, 'warning'],
|
[monaco.MarkerSeverity.Warning, 'warning'],
|
||||||
|
|
@ -72,7 +72,7 @@ export default function Footer({style}: {style?: CSSProperties}) {
|
||||||
/>
|
/>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
{/* <RenderExportButton /> */}
|
<MultimaterialColorsDialog />
|
||||||
{/* <Button
|
{/* <Button
|
||||||
icon="pi pi-bolt"
|
icon="pi pi-bolt"
|
||||||
onClick={() => model.render({isPreview: false, now: true})}
|
onClick={() => model.render({isPreview: false, now: true})}
|
||||||
|
|
|
||||||
119
src/components/MultimaterialColorsDialog.tsx
Normal file
119
src/components/MultimaterialColorsDialog.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import chroma from 'chroma-js';
|
||||||
|
import React, { useContext, useState } from 'react';
|
||||||
|
import { ColorPicker } from 'primereact/colorpicker';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { InputText } from 'primereact/inputtext';
|
||||||
|
import { ModelContext } from './contexts';
|
||||||
|
import { Dialog } from 'primereact/dialog';
|
||||||
|
|
||||||
|
export default function MultimaterialColorsDialog() {
|
||||||
|
const model = useContext(ModelContext);
|
||||||
|
if (!model) throw new Error('No model');
|
||||||
|
const state = model.state;
|
||||||
|
|
||||||
|
const [tempExtruderColors, setTempExtruderColors] = useState<string[]>(state.params.extruderColors ?? []);
|
||||||
|
|
||||||
|
function setColor(index: number, color: string) {
|
||||||
|
setTempExtruderColors(tempExtruderColors.map((c, i) => i === index ? color : c));
|
||||||
|
}
|
||||||
|
function removeColor(index: number) {
|
||||||
|
setTempExtruderColors(tempExtruderColors.filter((c, i) => i !== index));
|
||||||
|
}
|
||||||
|
function addColor() {
|
||||||
|
setTempExtruderColors([...tempExtruderColors, '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelExtruderPicker = () => {
|
||||||
|
setTempExtruderColors(state.params.extruderColors ?? []);
|
||||||
|
model!.mutate(s => s.view.extruderPickerVisibility = undefined);
|
||||||
|
};
|
||||||
|
const canAddColor = !tempExtruderColors.some(c => c.trim() === '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
header="Multimaterial Color Picker"
|
||||||
|
visible={!!state.view.extruderPickerVisibility}
|
||||||
|
onHide={cancelExtruderPicker}
|
||||||
|
footer={
|
||||||
|
<div>
|
||||||
|
<Button label="Cancel" icon="pi pi-times" onClick={cancelExtruderPicker} className="p-button-text" />
|
||||||
|
<Button
|
||||||
|
label={state.view.extruderPickerVisibility == 'exporting' ? "Export" : "Save"}
|
||||||
|
icon="pi pi-check"
|
||||||
|
disabled={!tempExtruderColors.every(c => chroma.valid(c))}
|
||||||
|
autoFocus
|
||||||
|
onClick={e => {
|
||||||
|
model!.mutate(s => {
|
||||||
|
s.params.extruderColors = tempExtruderColors;
|
||||||
|
s.view.extruderPickerVisibility = undefined;
|
||||||
|
});
|
||||||
|
if (state.view.extruderPickerVisibility === 'exporting') {
|
||||||
|
model!.export();
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-column align-items-center">
|
||||||
|
<div>
|
||||||
|
To print on a multimaterial printer using PrusaSlicer, BambuSlicer or OrcaSlicer, we map the model's colors to the closest match in the list of extruder colors.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Please define the colors of your extruders below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2" style={{
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{tempExtruderColors.map((color, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<ColorPicker
|
||||||
|
value={chroma.valid(color) ? chroma(color).hex() : 'black'}
|
||||||
|
onChange={(e) => e.value && setColor(index, chroma(e.value.toString()).name())}
|
||||||
|
/>
|
||||||
|
<InputText
|
||||||
|
value={color}
|
||||||
|
autoFocus={color === ''}
|
||||||
|
invalid={color.trim() === '' || !chroma.valid(color)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && canAddColor) {
|
||||||
|
e.preventDefault();
|
||||||
|
addColor();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
let color = e.target.value;
|
||||||
|
try {
|
||||||
|
color = chroma(e.target.value).name();
|
||||||
|
console.log(`color: ${e.target.value} -> ${color}`);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
setColor(index, color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
onClick={() => removeColor(index)}
|
||||||
|
className="p-button-danger p-button-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
label="Add Color"
|
||||||
|
disabled={!canAddColor}
|
||||||
|
icon="pi pi-plus"
|
||||||
|
text
|
||||||
|
onClick={addColor} className="p-button-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,11 +28,28 @@ function getColorMapping(colors: chroma.Color[], projectedColors: chroma.Color[]
|
||||||
// Reverse-engineered from PrusaSlicer / BambuStudio's output.
|
// Reverse-engineered from PrusaSlicer / BambuStudio's output.
|
||||||
const PAINT_COLOR_MAP = ['', '8', '0C', '1C', '2C', '3C', '4C', '5C', '6C', '7C', '8C', '9C', 'AC', 'BC', 'CC', 'DC'];
|
const PAINT_COLOR_MAP = ['', '8', '0C', '1C', '2C', '3C', '4C', '5C', '6C', '7C', '8C', '9C', 'AC', 'BC', 'CC', 'DC'];
|
||||||
|
|
||||||
export function exportOffTo3MF(data: IndexedPolyhedron, extruderColors?: chroma.Color[]): Blob {
|
export function export3MF(data: IndexedPolyhedron, extruderColors?: chroma.Color[]): Blob {
|
||||||
const objectUuid = uuidv4();
|
const objectUuid = uuidv4();
|
||||||
const buildUuid = uuidv4();
|
const buildUuid = uuidv4();
|
||||||
const paintColorByColorIndex = extruderColors &&
|
|
||||||
getColorMapping(data.colors.map(c => chroma.rgb(...c)), extruderColors).map(i => PAINT_COLOR_MAP[i]);
|
const dataColors = data.colors.map(([r, g, b, a]) => chroma.rgb(r*255, g*255, b*255, a*255));
|
||||||
|
const extruderIndexByColorIndex = extruderColors &&
|
||||||
|
getColorMapping(dataColors, extruderColors);
|
||||||
|
|
||||||
|
if (extruderColors) {
|
||||||
|
console.log('Extruder colors:');
|
||||||
|
for (const c of extruderColors) {
|
||||||
|
console.log(`- ${c.name()}`);
|
||||||
|
}
|
||||||
|
console.log('Model color mapping:');
|
||||||
|
dataColors.forEach((from, i) => {
|
||||||
|
const extruderIndex = extruderIndexByColorIndex![i];
|
||||||
|
const to = extruderColors[extruderIndex];
|
||||||
|
console.log(`- ${from.name()} -> ${to?.name()} (${PAINT_COLOR_MAP[extruderIndex]})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const paintColorByColorIndex = extruderIndexByColorIndex?.map(i => PAINT_COLOR_MAP[i]);
|
||||||
|
|
||||||
const archive = {
|
const archive = {
|
||||||
'3D/3dmodel.model': new TextEncoder().encode([
|
'3D/3dmodel.model': new TextEncoder().encode([
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,8 @@ export function parseOff(content: string): IndexedPolyhedron {
|
||||||
let colorIndex = colorMap.get(colorKey);
|
let colorIndex = colorMap.get(colorKey);
|
||||||
if (colorIndex == null) {
|
if (colorIndex == null) {
|
||||||
colorIndex = colors.length;
|
colorIndex = colors.length;
|
||||||
colors.push(color);
|
const [r, g, b, a] = color;
|
||||||
|
colors.push([r, g, b, a ?? 1]);
|
||||||
colorMap.set(colorKey, colorIndex);
|
colorMap.set(colorKey, colorIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,12 @@ export interface State {
|
||||||
features: string[],
|
features: string[],
|
||||||
exportFormat2D: keyof typeof VALID_EXPORT_FORMATS_2D,
|
exportFormat2D: keyof typeof VALID_EXPORT_FORMATS_2D,
|
||||||
exportFormat3D: keyof typeof VALID_EXPORT_FORMATS_3D,
|
exportFormat3D: keyof typeof VALID_EXPORT_FORMATS_3D,
|
||||||
|
extruderColors?: string[],
|
||||||
},
|
},
|
||||||
|
|
||||||
view: {
|
view: {
|
||||||
logs?: boolean,
|
logs?: boolean,
|
||||||
extruderPicker?: boolean,
|
extruderPickerVisibility?: 'editing' | 'exporting',
|
||||||
layout: {
|
layout: {
|
||||||
mode: 'single',
|
mode: 'single',
|
||||||
focus: SingleLayoutComponentId,
|
focus: SingleLayoutComponentId,
|
||||||
|
|
|
||||||
|
|
@ -66,12 +66,13 @@ export async function readStateFromFragment(): Promise<State | null> {
|
||||||
vars: params?.vars, // TODO: validate!
|
vars: params?.vars, // TODO: validate!
|
||||||
// Source deserialization also handles legacy links (source + sourcePath)
|
// Source deserialization also handles legacy links (source + sourcePath)
|
||||||
sources: params?.sources ?? (params?.source ? [{path: params?.sourcePath, content: params?.source}] : undefined), // TODO: validate!
|
sources: params?.sources ?? (params?.source ? [{path: params?.sourcePath, content: params?.source}] : undefined), // TODO: validate!
|
||||||
exportFormat2D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_2D), s => 'svg'),
|
exportFormat2D: validateStringEnum(params?.exportFormat2D, Object.keys(VALID_EXPORT_FORMATS_2D), s => 'svg'),
|
||||||
exportFormat3D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'glb'),
|
exportFormat3D: validateStringEnum(params?.exportFormat3D, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'glb'),
|
||||||
|
extruderColors: validateArray(params?.extruderColors, validateString, () => undefined as any as []),
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
logs: validateBoolean(view?.logs),
|
logs: validateBoolean(view?.logs),
|
||||||
extruderPicker: validateBoolean(view?.extruderPicker),
|
extruderPickerVisibility: validateStringEnum(view?.extruderPickerVisibility, ['editing', 'exporting'], s => undefined),
|
||||||
layout: {
|
layout: {
|
||||||
mode: validateStringEnum(view?.layout?.mode, ['multi', 'single']),
|
mode: validateStringEnum(view?.layout?.mode, ['multi', 'single']),
|
||||||
focus: validateStringEnum(view?.layout?.focus, ['editor', 'viewer', 'customizer'], s => false),
|
focus: validateStringEnum(view?.layout?.focus, ['editor', 'viewer', 'customizer'], s => false),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import { ProcessStreams } from "../runner/openscad-runner";
|
||||||
import { is2DFormatExtension } from "./formats";
|
import { is2DFormatExtension } from "./formats";
|
||||||
import { parseOff } from "../io/import_off";
|
import { parseOff } from "../io/import_off";
|
||||||
import { exportGlb } from "../io/export_glb";
|
import { exportGlb } from "../io/export_glb";
|
||||||
import { exportOffTo3MF as export3MF } from "../io/export_3mf";
|
import { export3MF } from "../io/export_3mf";
|
||||||
|
import chroma from "chroma-js";
|
||||||
|
|
||||||
export class Model {
|
export class Model {
|
||||||
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void,
|
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void,
|
||||||
|
|
@ -208,6 +209,10 @@ export class Model {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!this.state.is2D && this.state.params.exportFormat3D == '3mf' && !this.state.params.extruderColors) {
|
||||||
|
this.mutate(s => this.state.view.extruderPickerVisibility = 'exporting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.mutate(s => {
|
this.mutate(s => {
|
||||||
s.currentRunLogs ??= [];
|
s.currentRunLogs ??= [];
|
||||||
s.exporting = true;
|
s.exporting = true;
|
||||||
|
|
@ -225,7 +230,7 @@ export class Model {
|
||||||
if (exportFormat === '3mf') {
|
if (exportFormat === '3mf') {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const data = parseOff(await this.state.output.outFile.text());
|
const data = parseOff(await this.state.output.outFile.text());
|
||||||
const exportedData = export3MF(data);
|
const exportedData = export3MF(data, this.state.params.extruderColors?.map(c => chroma(c)));
|
||||||
const elapsedMillis = performance.now() - start;
|
const elapsedMillis = performance.now() - start;
|
||||||
output = {
|
output = {
|
||||||
outFile: new File([exportedData], this.state.output.outFile.name.replace('.off', '.3mf')),
|
outFile: new File([exportedData], this.state.output.outFile.name.replace('.off', '.3mf')),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue