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') {
|
||||
event.preventDefault();
|
||||
model.render({isPreview: false, now: true})
|
||||
} else if (event.key === 'F7') {
|
||||
event.preventDefault();
|
||||
model.export();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React, { useContext } from 'react';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { ModelContext } from './contexts';
|
||||
|
||||
import { SplitButton } from 'primereact/splitbutton';
|
||||
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}) {
|
||||
const model = useContext(ModelContext);
|
||||
|
|
@ -56,6 +56,14 @@ export default function ExportButton({className, style}: {className?: string, st
|
|||
icon: 'pi pi-file',
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Toast } from 'primereact/toast';
|
|||
import HelpMenu from './HelpMenu';
|
||||
import ExportButton from './ExportButton';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
import MultimaterialColorsDialog from './MultimaterialColorsDialog';
|
||||
|
||||
|
||||
export default function Footer({style}: {style?: CSSProperties}) {
|
||||
|
|
@ -19,7 +20,6 @@ export default function Footer({style}: {style?: CSSProperties}) {
|
|||
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
|
||||
const severityByMarkerSeverity = new Map<monaco.MarkerSeverity, 'danger' | 'warning' | 'info'>([
|
||||
[monaco.MarkerSeverity.Error, 'danger'],
|
||||
[monaco.MarkerSeverity.Warning, 'warning'],
|
||||
|
|
@ -72,7 +72,7 @@ export default function Footer({style}: {style?: CSSProperties}) {
|
|||
/>
|
||||
) : undefined
|
||||
}
|
||||
{/* <RenderExportButton /> */}
|
||||
<MultimaterialColorsDialog />
|
||||
{/* <Button
|
||||
icon="pi pi-bolt"
|
||||
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.
|
||||
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 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 = {
|
||||
'3D/3dmodel.model': new TextEncoder().encode([
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ export function parseOff(content: string): IndexedPolyhedron {
|
|||
let colorIndex = colorMap.get(colorKey);
|
||||
if (colorIndex == null) {
|
||||
colorIndex = colors.length;
|
||||
colors.push(color);
|
||||
const [r, g, b, a] = color;
|
||||
colors.push([r, g, b, a ?? 1]);
|
||||
colorMap.set(colorKey, colorIndex);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,12 @@ export interface State {
|
|||
features: string[],
|
||||
exportFormat2D: keyof typeof VALID_EXPORT_FORMATS_2D,
|
||||
exportFormat3D: keyof typeof VALID_EXPORT_FORMATS_3D,
|
||||
extruderColors?: string[],
|
||||
},
|
||||
|
||||
view: {
|
||||
logs?: boolean,
|
||||
extruderPicker?: boolean,
|
||||
extruderPickerVisibility?: 'editing' | 'exporting',
|
||||
layout: {
|
||||
mode: 'single',
|
||||
focus: SingleLayoutComponentId,
|
||||
|
|
|
|||
|
|
@ -66,12 +66,13 @@ export async function readStateFromFragment(): Promise<State | null> {
|
|||
vars: params?.vars, // TODO: validate!
|
||||
// Source deserialization also handles legacy links (source + sourcePath)
|
||||
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'),
|
||||
exportFormat3D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'glb'),
|
||||
exportFormat2D: validateStringEnum(params?.exportFormat2D, Object.keys(VALID_EXPORT_FORMATS_2D), s => 'svg'),
|
||||
exportFormat3D: validateStringEnum(params?.exportFormat3D, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'glb'),
|
||||
extruderColors: validateArray(params?.extruderColors, validateString, () => undefined as any as []),
|
||||
},
|
||||
view: {
|
||||
logs: validateBoolean(view?.logs),
|
||||
extruderPicker: validateBoolean(view?.extruderPicker),
|
||||
extruderPickerVisibility: validateStringEnum(view?.extruderPickerVisibility, ['editing', 'exporting'], s => undefined),
|
||||
layout: {
|
||||
mode: validateStringEnum(view?.layout?.mode, ['multi', 'single']),
|
||||
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 { parseOff } from "../io/import_off";
|
||||
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 {
|
||||
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void,
|
||||
|
|
@ -208,6 +209,10 @@ export class Model {
|
|||
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 => {
|
||||
s.currentRunLogs ??= [];
|
||||
s.exporting = true;
|
||||
|
|
@ -225,7 +230,7 @@ export class Model {
|
|||
if (exportFormat === '3mf') {
|
||||
const start = performance.now();
|
||||
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;
|
||||
output = {
|
||||
outFile: new File([exportedData], this.state.output.outFile.name.replace('.off', '.3mf')),
|
||||
|
|
|
|||
Loading…
Reference in a new issue