partforge 0.134.0 → 0.136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ import { ensureBoxUVs } from "./materials/uv.js";
12
12
  import { loadEnvironmentRig } from "./materials/environment.js";
13
13
  import { assetUrl } from "./materials/assets.js";
14
14
  import { resolveEnvironmentId } from "./materials/resolve.js";
15
+ import { burnsFor, realisticDisplay } from "./materials/sheet-look.js";
15
16
  import { neutralToneMapToSrgb8 } from "./materials/tonemap-readback.js";
16
17
  import { createCutaway } from "./cutaway.js";
17
18
  import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
@@ -591,9 +592,11 @@ export function createViewer(container, part) {
591
592
  const textureCache = new Map(); // asset file name -> Texture
592
593
  const textureLoader = new THREE.TextureLoader();
593
594
  let pmrem = null;
594
- let printFrames = {};
595
- let printFrameSource = null; // () => frames; mount's lazy provider (setPrintFrameSource)
596
- let printFramesStale = false; // the source has frames the materials have not seen
595
+ let printFrames = {}; // name -> mat4 array: layer lines' display → export map
596
+ let sheetFrames = {}; // name -> { frame, t, plies }: a burning sheet's canonical frame (sheet-look.js)
597
+ let frameSource = null; // () => { print, sheet }; mount's lazy provider (setFrameSource)
598
+ let framesStale = false; // the source has frames the materials have not seen
599
+ const IDENTITY_MAT4 = new THREE.Matrix4().toArray();
597
600
  const modeListeners = new Set();
598
601
  const envListeners = new Set();
599
602
  const SHADOW_LOWRES_MS = 100; // at most one low-res shadow per this, while a part moves
@@ -728,9 +731,14 @@ export function createViewer(container, part) {
728
731
  function physicalFor(name) {
729
732
  let m = physicalMats.get(name);
730
733
  if (!m) {
731
- m = cloneKeepsPattern(buildPhysicalMaterial(part.parts[name].display, { printFrame: printFrames[name], loadTexture }));
734
+ const sp = part.parts[name];
735
+ // `burn` is decided once per sub-part (sheet-look.js burnsFor): only a laser sheet in
736
+ // a wood compiles the burn pass, so every other program is exactly what it was.
737
+ // realisticDisplay: a laser sheet with no material draws as its stock (sheet-look.js).
738
+ m = cloneKeepsPattern(buildPhysicalMaterial(realisticDisplay(sp), { printFrame: printFrames[name], burn: burnsFor(sp), loadTexture }));
732
739
  physicalMats.set(name, m);
733
740
  syncGrain(name);
741
+ applySheetFrame(m, sheetFrames[name]);
734
742
  }
735
743
  return m;
736
744
  }
@@ -859,7 +867,7 @@ export function createViewer(container, part) {
859
867
  // ground only moves on showAssembly, never because a capture happened.
860
868
  function enterRealistic(rig, { live = true, reground = true } = {}) {
861
869
  try {
862
- syncPrintFrames(); // before the swap: the first realistic frame has the export-pose layers
870
+ syncFrames(); // before the swap: the first realistic frame has the export-pose layers and the sheets' burns
863
871
  renderMode = "realistic";
864
872
  // A regen can land between the proxy compile and here, while the mode
865
873
  // was still CAD, so setSubGeometry skipped the UVs. Idempotent.
@@ -929,7 +937,7 @@ export function createViewer(container, part) {
929
937
  // target bound — each only for the synchronous compile() inside
930
938
  // compileAsync, so the view on screen is untouched while it waits.
931
939
  function compileRealistic(rig, { forCapture = false } = {}) {
932
- syncPrintFrames();
940
+ syncFrames();
933
941
  const proxy = new THREE.Scene();
934
942
  proxy.environment = rig.envMap;
935
943
  for (const n of names) {
@@ -1030,30 +1038,45 @@ export function createViewer(container, part) {
1030
1038
  for (const [n, m] of physicalMats) m.userData.patternUniforms?.pfPrintFrame.value.fromArray(printFrames[n] ?? identity);
1031
1039
  }
1032
1040
 
1033
- // The lazy form mount uses. Computing a frame runs the sub-part's pose probe
1034
- // (two geometry-free builds for a placed sub-part), and every sub-part with
1035
- // no material draws layer lines, so frames are PULLED only when something is
1036
- // about to draw the realistic look: the live view (or a switch loading),
1037
- // and any capture that borrows it — compileRealistic and enterRealistic are
1038
- // the two doors every such path goes through. A delivery just marks them
1039
- // stale (invalidatePrintFrames); in CAD nothing is computed.
1040
- function setPrintFrameSource(source) {
1041
- printFrameSource = typeof source === "function" ? source : null;
1042
- invalidatePrintFrames();
1043
- }
1044
- function invalidatePrintFrames() {
1045
- printFramesStale = true;
1046
- if (renderMode === "realistic" || realisticPending) syncPrintFrames();
1047
- }
1048
- function syncPrintFrames() {
1049
- if (!printFrameSource || !printFramesStale) return;
1050
- printFramesStale = false;
1041
+ // A burning sheet's canonical frame (sheet-look.js sheetFrameFor). No frame — not yet
1042
+ // delivered, or not computable at these params — is pfSheetT = 0: plain wood.
1043
+ function applySheetFrame(m, f) {
1044
+ const u = m.userData.patternUniforms;
1045
+ if (!u?.pfSheetFrame) return;
1046
+ u.pfSheetFrame.value.fromArray(f?.frame ?? IDENTITY_MAT4);
1047
+ u.pfSheetT.value = f?.t ?? 0;
1048
+ u.pfPlies.value = f?.plies ?? 0;
1049
+ }
1050
+ function setSheetFrames(frames) {
1051
+ sheetFrames = frames ?? {};
1052
+ for (const [n, m] of physicalMats) applySheetFrame(m, sheetFrames[n]);
1053
+ }
1054
+
1055
+ // The lazy form mount uses. Frames are PULLED only when something is about to draw the
1056
+ // realistic look: the live view (or a switch loading), and any capture that borrows it —
1057
+ // compileRealistic and enterRealistic are the two doors every such path goes through. A
1058
+ // delivery just marks them stale (invalidateFrames); in CAD nothing is computed. One
1059
+ // source carries both kinds: `print` (layer lines; a placed sub-part's costs two
1060
+ // geometry-free probe builds, and every sub-part with no material draws layer lines) and
1061
+ // `sheet` (a burning sheet's canonical frame, from data).
1062
+ function setFrameSource(source) {
1063
+ frameSource = typeof source === "function" ? source : null;
1064
+ invalidateFrames();
1065
+ }
1066
+ function invalidateFrames() {
1067
+ framesStale = true;
1068
+ if (renderMode === "realistic" || realisticPending) syncFrames();
1069
+ }
1070
+ function syncFrames() {
1071
+ if (!frameSource || !framesStale) return;
1072
+ framesStale = false;
1051
1073
  let frames;
1052
- try { frames = printFrameSource(); } catch (e) {
1053
- console.warn("partforge: computing print frames failed", e);
1074
+ try { frames = frameSource(); } catch (e) {
1075
+ console.warn("partforge: computing material frames failed", e);
1054
1076
  return;
1055
1077
  }
1056
- setPrintFrames(frames);
1078
+ setPrintFrames(frames?.print);
1079
+ setSheetFrames(frames?.sheet);
1057
1080
  }
1058
1081
 
1059
1082
  // Resolves once the current environment's rig is loaded (captures wait on it).
@@ -2735,8 +2758,9 @@ export function createViewer(container, part) {
2735
2758
  isRealisticPending: () => realisticPending,
2736
2759
  onEnvironmentChange: (cb) => { envListeners.add(cb); return () => envListeners.delete(cb); },
2737
2760
  setPrintFrames,
2738
- setPrintFrameSource,
2739
- invalidatePrintFrames,
2761
+ setSheetFrames,
2762
+ setFrameSource,
2763
+ invalidateFrames,
2740
2764
  whenRealisticReady,
2741
2765
  dispose,
2742
2766
  }, () => requestRender(API_GRACE_MS));
@@ -42,7 +42,7 @@ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
42
42
  [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
43
43
  k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
44
44
 
45
- const PLY = { views: ["box"], display: { material: "oak" }, material: "birch plywood", thickness: (p) => p.t };
45
+ const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
46
46
  const panel = (name, label, extra = {}) => sheetPart({
47
47
  ...PLY, label,
48
48
  profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
@@ -1,7 +1,10 @@
1
1
  // Dev-only contact sheet for the material library: one 30 mm sample per preset,
2
2
  // laid out in a grid, so a change to a preset or the pattern shader can be
3
- // judged by eye in every environment (materials.html). Not a reference part.
3
+ // judged by eye in every environment (materials.html). The "Laser-cut" views
4
+ // (below) do the same for the laser burn. Not a reference part.
4
5
  import { PRESETS } from "../framework/materials/presets.js";
6
+ import { sheetPart } from "../framework/sheet/part.js";
7
+ import { sheetHole } from "../framework/sheet/joinery.js";
5
8
 
6
9
  const ids = Object.keys(PRESETS);
7
10
  const COLS = 6, PITCH = 40;
@@ -19,10 +22,59 @@ const FILAMENT_COLOURS = [
19
22
  const withHole = (k) =>
20
23
  k.box({ size: [30, 30, 12] }).fillet({ r: 3 }).cut(k.cylinder({ r: 5, h: 20 }).translate([0, 0, -4]));
21
24
 
25
+ // The "Laser-cut" view: sheet swatches as the laser burn draws them — each 30 × 30 with a
26
+ // cut hole, an engraved label (its thickness) and a score line — in every wood at 3, 6 and
27
+ // 9 mm; one oak panel standing on edge (posed, so its frame is not the identity); and the
28
+ // looks that must NOT burn: a clear-acrylic sheet and an oak block that is not a sheet part.
29
+ // "Laser-cut, unburnt" is its twin: the same swatches with an identity author `place`,
30
+ // which shows plain wood (materials/sheet-look.js) — same geometry, same place, same
31
+ // material — so the two views differ ONLY by the burn and scripts/capture-contact-sheet.mjs
32
+ // can diff them pixel for pixel — and one row of sheets that name no material, drawn as
33
+ // their stock (sheet-look.js STOCK_LOOKS).
34
+ const SW = 30;
35
+ const WOODS = ["plywood", "oak", "walnut"];
36
+ const CONTROLS_Y = -WOODS.length * PITCH;
37
+ const LASER_SWATCHES = [
38
+ ...WOODS.flatMap((material, row) => [3, 6, 9].map((t, col) => ({
39
+ key: `${material}_${t}`, label: `${PRESETS[material].label}, ${t} mm`, at: [col * PITCH, -row * PITCH], t, display: { material },
40
+ }))),
41
+ { key: "acrylic_3", label: "Clear acrylic, 3 mm", at: [0, CONTROLS_Y], t: 3, display: { material: "clear-acrylic" }, stock: "clear acrylic" },
42
+ { key: "oak_standing", label: "Oak, 3 mm, standing", at: [PITCH, CONTROLS_Y], t: 3, display: { material: "oak" }, standing: true },
43
+ { key: "oak_block", label: "Oak block (not a sheet part)", at: [2 * PITCH, CONTROLS_Y], t: 3, display: { material: "oak" }, block: true },
44
+ { key: "stock_plywood", label: "No material, \"birch plywood\" stock", at: [0, CONTROLS_Y - PITCH], t: 3, stock: "birch plywood" },
45
+ { key: "stock_acrylic", label: "No material, \"clear acrylic\" stock", at: [PITCH, CONTROLS_Y - PITCH], t: 3, stock: "clear acrylic" },
46
+ { key: "stock_stained", label: "A bare colour on \"birch plywood\" stock", at: [2 * PITCH, CONTROLS_Y - PITCH], t: 3, stock: "birch plywood", display: { color: 0x2e8b3d } },
47
+ ];
48
+
49
+ function laserSwatch(s, unburnt) {
50
+ const [x, y] = s.at;
51
+ const common = {
52
+ label: unburnt ? `${s.label} (unburnt)` : s.label,
53
+ views: [unburnt ? "unburnt" : "laser"],
54
+ ...(s.display ? { display: s.display } : {}),
55
+ };
56
+ if (s.block) {
57
+ return { ...common, build: (k) => k.box({ min: [x, y, 0], max: [x + SW, y + SW, s.t] })
58
+ .cut(k.cylinder({ d: 8, h: s.t + 2 }).translate([x + 21, y + 21, -1])) };
59
+ }
60
+ // A standing swatch is drawn at the drawing origin; its pose stands it on edge at `at`.
61
+ const [u, v] = s.standing ? [0, 0] : [x, y];
62
+ return sheetPart({
63
+ ...common,
64
+ material: s.stock ?? "birch plywood",
65
+ thickness: s.t,
66
+ profile: (k) => k.shape2d([[u, v], [u + SW, v], [u + SW, v + SW], [u, v + SW]]).cut(sheetHole({ d: 8, at: [u + 21, v + 21] })),
67
+ engrave: (k) => k.text2d(String(s.t), { size: 10, align: "center", valign: "middle" }).translate([u + 9, v + 20]),
68
+ score: () => [[[u + 3, v + 7], [u + SW - 3, v + 7]]],
69
+ ...(s.standing ? { pose: { face: "-Y", up: "+Z", at: [x, y, 0] } } : {}),
70
+ ...(unburnt ? { place: (solid) => solid } : {}),
71
+ });
72
+ }
73
+
22
74
  export default {
23
75
  meta: { title: "Material swatches", units: "mm", environment: "studio" },
24
76
  defaults: {},
25
- views: { all: { label: "All presets", default: true }, colours: { label: "PLA colours" }, petg: { label: "PETG colours" }, print: { label: "Layer lines" } },
77
+ views: { all: { label: "All presets", default: true }, colours: { label: "PLA colours" }, petg: { label: "PETG colours" }, print: { label: "Layer lines" }, laser: { label: "Laser-cut" }, unburnt: { label: "Laser-cut, unburnt" } },
26
78
  parts: {
27
79
  ...Object.fromEntries(ids.map((id, i) => [id.replaceAll("-", "_"), {
28
80
  label: PRESETS[id].label,
@@ -65,5 +117,9 @@ export default {
65
117
  build: (k) => k.box({ size: [30, 12, 40] }).translate([50, 0, 0]),
66
118
  display: { material: "pla-print", color: 0xe0592a },
67
119
  },
120
+ ...Object.fromEntries(LASER_SWATCHES.flatMap((s) => [
121
+ [`laser_${s.key}`, laserSwatch(s, false)],
122
+ [`unburnt_${s.key}`, laserSwatch(s, true)],
123
+ ])),
68
124
  },
69
125
  };
package/types/part.d.ts CHANGED
@@ -441,6 +441,10 @@ export interface SheetDeclaration<P = ResolvedParams, D = Derived> {
441
441
  pose: SheetPose | ((p: P, d: D) => SheetPose | null) | null;
442
442
  /** The build `sheetPart` made; `sub.build !== sub.sheet.generatedBuild` is a custom build. */
443
443
  generatedBuild: (k: GeometryKernel, p: P, d: D) => Solid;
444
+ /** The author's own `place` (the spec's), run after the pose; `null` when there is none. */
445
+ place: ((solid: Solid, ctx: PlaceContext<P, D>) => Solid) | null;
446
+ /** The `place` `sheetPart` installed (the pose, then the author's own), or `null`; `sub.place !== sub.sheet.generatedPlace` is a replaced place. */
447
+ generatedPlace: ((solid: Solid, ctx: PlaceContext<P, D>) => Solid) | null;
444
448
  }
445
449
 
446
450
  export interface ViewDefinition {