The math bug
For a frustum tier unrolled to an annulus, the slant distances are L = R / sin(α). For a contracting tier (top radius > bottom radius… i.e. Rt > Rb going down, e.g. the tulip profile's last tier 1200→1000), L_top > L_bot. Three places assume L_top < L_bot:
lampGeometry.ts:287 — if (d < m.L_top - 0.5 || d > m.L_bot + 0.5) continue; — for a contracting tier every interior point satisfies d < L_top - 0.5, so patternToSurface returns null for the entire tier. Example: profile [{r:800,y:0},{r:400,y:1000}] → L_top≈2154, L_bot≈1077, valid d∈[1077,2154] is entirely rejected.
lampGeometry.ts:291, lampGeometry.ts:502, Lamp3DPreview.tsx:241 — v = (d - L_top) / Math.max(1e-6, L_bot - L_top): the legitimately negative denominator (numerator and denominator both negative → v would be correct) is clamped to 1e-6, yielding v ≈ −5×10⁸, then clamped to 0 — every vertex collapses onto the tier's top ring.
angleRel = atan2(bisectorSign*dx, bisectorSign*dy) (lampGeometry.ts:288/503, Lamp3DPreview.tsx:242) makes theta01 decrease with pattern-x for contracting tiers but increase for cylinders/expanding tiers → contracting tiers render horizontally mirrored relative to the rest of the lamp.
Why it's destructive, not just cosmetic
patternToSurfaceRobust has the same v bug, and reflowLampPoints (used live by LampProfileDialog.tsx:88-125 and on confirm) runs drawn polygons through it. Changing the profile with a contracting tier collapses the user's polygons onto an arc (zero area) — committed into project state, autosaved 500 ms later. Undo is the only recovery, and only if noticed immediately.
Repro
- Pick the tulip profile (last tier contracts) and set the facet slider to Smooth.
- Pieces in the contracting tier vanish from the 3D preview (
vertexTo3D → null → skip at Lamp3DPreview.tsx:301-315) or collapse onto the top ring.
- Edit the profile → drawn pieces in that tier are flattened to arcs in the 2D pattern itself.
Related degenerate-tier bugs (same file)
- Two consecutive profile points with equal y and r (easy to create — the editor's alignment snap at
LampProfileDialog.tsx:416-420 sets exact values, and commit's sort at line 176 doesn't dedupe) produce a height = 0 cylinder tier → (py - m.topY) / m.height is 0/0 → NaN → Math.max(0, Math.min(1, NaN)) is NaN → silently corrupts the position buffer (lampGeometry.ts:279, Lamp3DPreview.tsx:235).
- Profile points are only sorted on commit (
LampProfileDialog.tsx:176), so live preview during editing feeds negative-height tiers to layout, and a profile drawn out of order commits a geometry the user never previewed.
- A nearly-flat tier (sinα→1) unrolls to a θ≈2π annulus that overlaps the tiers stacked above/below in the 2D layout;
patternToSurface (lampGeometry.ts:273) returns the first containing tier, so pieces drawn in the overlap silently map to the wrong tier — and the flat pattern is ambiguous/uncuttable there.
(For the record: the faceted trapezoid math and the expanding-tier unroll constants — θ = 2π·sinα, L = R/sinα — are correct; modeling each tier as a developable frustum is the right approach. It's specifically the contracting-tier orientation and the degenerate cases that are broken.)
The math bug
For a frustum tier unrolled to an annulus, the slant distances are
L = R / sin(α). For a contracting tier (top radius > bottom radius… i.e.Rt > Rbgoing down, e.g. the tulip profile's last tier 1200→1000),L_top > L_bot. Three places assumeL_top < L_bot:lampGeometry.ts:287—if (d < m.L_top - 0.5 || d > m.L_bot + 0.5) continue;— for a contracting tier every interior point satisfiesd < L_top - 0.5, sopatternToSurfacereturnsnullfor the entire tier. Example: profile[{r:800,y:0},{r:400,y:1000}]→ L_top≈2154, L_bot≈1077, valid d∈[1077,2154] is entirely rejected.lampGeometry.ts:291,lampGeometry.ts:502,Lamp3DPreview.tsx:241—v = (d - L_top) / Math.max(1e-6, L_bot - L_top): the legitimately negative denominator (numerator and denominator both negative → v would be correct) is clamped to1e-6, yielding v ≈ −5×10⁸, then clamped to 0 — every vertex collapses onto the tier's top ring.angleRel = atan2(bisectorSign*dx, bisectorSign*dy)(lampGeometry.ts:288/503,Lamp3DPreview.tsx:242) makes theta01 decrease with pattern-x for contracting tiers but increase for cylinders/expanding tiers → contracting tiers render horizontally mirrored relative to the rest of the lamp.Why it's destructive, not just cosmetic
patternToSurfaceRobusthas the samevbug, andreflowLampPoints(used live byLampProfileDialog.tsx:88-125and on confirm) runs drawn polygons through it. Changing the profile with a contracting tier collapses the user's polygons onto an arc (zero area) — committed into project state, autosaved 500 ms later. Undo is the only recovery, and only if noticed immediately.Repro
vertexTo3D→ null → skip atLamp3DPreview.tsx:301-315) or collapse onto the top ring.Related degenerate-tier bugs (same file)
LampProfileDialog.tsx:416-420sets exact values, and commit's sort at line 176 doesn't dedupe) produce aheight = 0cylinder tier →(py - m.topY) / m.heightis 0/0 → NaN →Math.max(0, Math.min(1, NaN))is NaN → silently corrupts the position buffer (lampGeometry.ts:279,Lamp3DPreview.tsx:235).LampProfileDialog.tsx:176), so live preview during editing feeds negative-height tiers to layout, and a profile drawn out of order commits a geometry the user never previewed.patternToSurface(lampGeometry.ts:273) returns the first containing tier, so pieces drawn in the overlap silently map to the wrong tier — and the flat pattern is ambiguous/uncuttable there.(For the record: the faceted trapezoid math and the expanding-tier unroll constants — θ = 2π·sinα, L = R/sinα — are correct; modeling each tier as a developable frustum is the right approach. It's specifically the contracting-tier orientation and the degenerate cases that are broken.)