Chris X Edwards

All nails become toxic waste. I am now certain that I have used a hammer to remove more nails than to install them.
2026-09-29 07:48
Taking a break while waiting for an LLM to help with your program is the new compile break.
2026-09-23 09:32
If instead of destroying the internet, Google search had steadily improved since 2006, it would by now be exactly as helpful as modern AI.
2026-09-04 07:42
For me AI's productivity perfectly offsets its oversight needs; now my efforts accomplish the same but I'm given less credit.
2026-08-27 09:13
While no means no, sometimes nay means yes, like when speaking Greek. Rejecting near universally honored PIE heritage Ναι means Yes.
2026-08-19 17:44
Blah Blah
--------------------------

Colorful Trees

2026-10-07 16:06

The trees have been changing color here in the UP in the last couple of weeks and it’s been quite spectacular. Sometimes the leaves barely turn and high winds immediately strip them off. This year we’ve had some time to enjoy them. Here are some images taken on October 5, 2026.

fall01.jpg

fall02.jpg

fall03.jpg

fall04.jpg

fall05.jpg

fall06.jpg

fall07.jpg

fall08.jpg

fall09.jpg

fall11.jpg

fall12.jpg

fall10.jpg

If you really want to see more of that, they were extracted from a video I took rollerskiing. Sorry about the wind noise, but, hey, it was very windy. Turn on the subtitles which, impressively, were automatically generated and 99.5% correct; I’m going to go ahead and declare speech recognition solved.

Here are some bonus photos taken with a proper camera.

IMG_9000.jpg

IMG_9001.jpg

IMG_9002.jpg

IMG_9003.jpg

The Error Of Significant Figures

2026-10-03 20:06

Decades ago when I first took a chemistry class the very first topic changed my life — and not in a good way. The topic was significant figures. At first it didn’t make sense to me but as time went on, it still never made sense. That was a red flag. When I took basic chemistry in college — same syllabus, same misgivings. When a friend was taking basic chemistry while I was working in a machine shop with my degree in Industrial Engineering complete, my misgivings were becoming convictions. There was something deeply wrong, or at least impractically facile, about how significant figures were portrayed in beginner chemistry classes.

I feel like the Introductory Chemistry Significant Figures (let’s call it ICSF) topic is well meaning but deeply flawed. ICSF does nothing to help one understand chemistry per se. A very strange and important question is: why is it not taught before biology or physics? Why has this been awkwardly bolted on to chemistry? Perhaps it is imagined that it could help with cooking recipes in your kitchen and I suppose the idea is that will at some point help with cooking recipes in a chem lab producing a concomitant chemistry understanding. But the problem is that it is just a muddled approximation that will lead to epistemological confusion when engineering problems become serious.

What is the problem with this? Here is XKCD showing an example of one issue.

I can ride my bike at 45mph. If you round.

It has been difficult for me to articulate exactly what the problem is because I never properly learned what these ICSF heuristics were. (To demonstrate I am not just imagining this, here is an example of ICSF learning materials and another.) I feel like I got through chemistry by trying to guess what a capricious insane person with a gun wanted me to say. (And then I had to spend the rest of the course being regarded as a "bad student" by what to me was a capricious insane person.)

I’ve wanted to write this post for decades, but I also didn’t want to think too hard about this topic. Today I realized that our robot friends can do the unpleasant donkey work and maybe I can get some closure for this educational trauma.

The Problem

What exactly is the ICSF method anyway? It is intended to restrain unwarranted digit exuberance when representing and using numbers. For example, if I ask what’s the speed limit or how many grams of nuts does this recipe call for, it is not optimal for the answer to be "45.00000000000000". More subtle problems arise if I tell you I have a cube that holds about a gallon and you tell me the sides must be 6.135792439661958 inches. Clearly that is not a good way to communicate or think. The ICSF is a well intentioned attempt to manage this.

If my chemistry teacher had pointed this problem out and told us to use good judgment and avoid excessive precision, well, that would have been fine. Instead ICSF was introduced and presented with the exact credibility and authority as, say, the Pythagorean Theorem.

ICSF Precisely Defined

Here we come to the part of this exploration where I do not have enough shits to give to dig into the muddled technique I was taught. This is where our robot friends are perfect for summarizing the normal thing that chemistry students are normally taught. I’m putting its summary here for completeness, but it is probably better for you philosophically to just skim past this without thinking too hard.

Counting significant figures

* Nonzero digits always count.
* Zeros between nonzero digits count (1005 has 4).
* Leading zeros never count (0.0045 has 2).
* Trailing zeros count only if there's a decimal point (100. has 3,
  1.50 has 3). In 100 they're placeholders, which is the ambiguous
  case. Scientific notation resolves it: 1.00E2.
* Exact numbers (counts, defined conversions like 100 cm/m) have
  infinitely many.

Arithmetic

* Multiply/divide: the result gets as many sig figs as the input with the fewest.
* Add/subtract: the result is rounded to the least precise decimal
  position among the inputs.
* Round only at the end, carrying extra guard digits in the middle.
* Logs: the digits after the decimal point in pH (the mantissa) equal
  the sig figs in [H⁺]. So pH 4.74 means [H⁺] has 2 sig figs.

Is this the "correct" heuristic? Maybe. When talking about textual number conversion algorithms, I personally like to communicate in a proper language designed for such things. Our robot friend helpfully translated that for me into this more serious expression of what exactly ICSF is. Again just skip right past this unless you’re taking a very keen interest, but this is an extraordinarily explicit description of what is taught to beginning chemistry students.

from decimal import Decimal, ROUND_HALF_UP
import re

class Val:
    """A number plus the position of its last significant digit."""
    def __init__(self, d, pos): self.d, self.pos = d, pos
    def n(self): return self.d.adjusted() - self.pos + 1      # sig figs

    def _prod(self, d, other):
        n = min(self.n(), other.n())
        return Val(d, d.adjusted() - n + 1)
    def __mul__(a, b):     return a._prod(a.d * b.d, b)
    def __truediv__(a, b): return a._prod(a.d / b.d, b)
    def __add__(a, b):     return Val(a.d + b.d, max(a.pos, b.pos))
    def __sub__(a, b):     return Val(a.d - b.d, max(a.pos, b.pos))

    def __str__(self):
        if self.pos < -50: return str(self.d)                # exact
        q = self.d.quantize(Decimal(1).scaleb(self.pos), ROUND_HALF_UP)
        if q.adjusted() > self.d.adjusted():                 # 9.99 -> 10, not 10.0
            q = self.d.quantize(Decimal(1).scaleb(self.pos + 1), ROUND_HALF_UP)
        return str(q)

def S(s):
    """Parse a number as written, e.g. S('0.0450'), S('100.'), S('1.5E3')."""
    d = Decimal(s)
    mant = re.split('[eE]', s)[0].lstrip('+-')
    digits = mant.replace('.', '').lstrip('0')
    if '.' not in mant: digits = digits.rstrip('0')          # trailing-zero convention
    return Val(d, d.adjusted() - max(len(digits), 1) + 1)

def E(s): return Val(Decimal(s), -999)                       # exact number

# Some tests...
print(S('12.11') + S('18.0') + S('1.013'))   # 31.1   (limited by 18.0)
print(S('4.2') * S('3.14159'))               # 13     (2 s.f.)
print(E('2') * S('3.14'))                    # 6.28

The point is that this is the algorithm I was expected to run in a virtual computer in my brain when I was a kid. I suspect this is mostly because it is something that involves "math" and can be easily graded. It is essentially a MacGuffin side quest of chemistry education.

The Problem With The Solution

ICSF is essentially a logarithmic proxy for relative uncertainty, and it is a questionable one. To start with, the ROUND_HALF_UP assumption in the program is just that, an assumption. (I have written before how computer rounding is much harder than you might think.)

There are also some different variants of ICSF. For example, is "100" three significant digits? Or one? Some ICSF systems clumsily insist on "100." to establish the full three. What if I want two?

Here are some reasonable problems my robot friend could enumerate about ICSF. It also was able to generate impressive plots based on actual extemporaneous simulation experiments to visually show the differences between ICSF (tan) and ground truth reality (blue).

* The 1.0 vs 9.9 cliff, now in logarithms - [H⁺] = 1.0×10⁻³ and [H⁺] =
  9.9×10⁻³ both have 2 sig figs, so both pH values get 2 decimals: pH
  3.00 and pH 2.00. But 1.0 is ±5%, which is ±0.02 in pH, so "3.00"
  overclaims by 4×. And 9.9 is ±0.5%, which is ±0.002 in pH, so "2.00"
  underclaims by 2×. Same rule, wildly different honesty.

icsf-1.png

* The rules violate the distributive law - (12.5 − 12.0) × 3.000: the
  subtraction gives 0.5 (1 sf), and times 3.000 gives 1.5, which
  rounds to 2. Distribute it instead: 12.5×3.000 − 12.0×3.000 = 37.5 −
  36.0 = 1.5. Same arithmetic, two different answers. The true value
  is about 1.50 ± 0.02, so the first answer is off by a factor of 25
  in its implied tolerance.

icsf-2.png

* Round trips don't return home (the xkcd effect) - 5 mph → 2 m/s
  (2.235, rounded to 1 sf) → 4 mph (4.47, rounded). One unit
  conversion each way and you've lost 20% of the value, even though
  both conversion factors are exact. The sig-fig interval for "5 mph"
  is 4.5–5.5 mph, but the interval for "2 m/s" is 1.5–2.5 m/s, which
  is 3.4–5.6 mph. The rule changes the implied tolerance every time
  the unit changes.

icsf-3.png

* It can't recognize that x − x = 0 or x/x = 1 - A sample is weighed
  once as 0.250 g. The mass fraction of Mg in pure Mg is 0.250/0.250 =
  "1.00 (±0.5%)", when it is exactly 1. Any quantity appearing in both
  numerator and denominator (or subtracted from itself) is perfectly
  correlated. Errors cancel exactly, and ICSF has no way to say so.

icsf-4.png

* Adding many terms understates uncertainty - Add 100 readings from a
  balance that resolves 0.1 g. ICSF says the total is good to 0.1 g
  (implied ±0.05). In reality each reading carries about 0.03 g of
  standard uncertainty. The sum's uncertainty is √100 × 0.03 ≈ 0.3 g,
  six times the implied tolerance, and in the worst case ±5 g.

icsf-5.png

* Averaging many terms overstates uncertainty - Ten titrations read
  12.3, 12.4, 12.3, 12.2, 12.3, 12.4, 12.3, 12.3, 12.4, 12.3 mL. The
  mean is 12.32, the standard deviation is 0.063, and the standard
  error of the mean is 0.02. ICSF says to report 12.3 and throw away
  the extra digit, which discards real information. (This works only
  because the readings actually scatter; identical readings can't be
  averaged past the resolution.) Examples 5 and 6 together show the
  rule is wrong in both directions depending on the operation.

icsf-6.png

* Digits are not accuracy - A Class A 100 mL volumetric flask is
  certified to ±0.08 mL. Writing "100.00 mL" implies ±0.005 mL, which
  is a 16× overclaim. Conversely, the rule would give a 100 mL
  graduated cylinder reading of "100. mL" a clean bill of health it
  hasn't earned. ICSF reads the number of digits you wrote down, not
  the instrument, calibration, or certificate.

icsf-7.png

* "100" means anything from ±0.5 to ±50 - Is "100 mL" 1, 2, or 3 sf?
  Textbooks disagree. At 1 sf the implied range is 50–150 mL. Multiply
  by a density of 1.234 g/mL and you get either 1×10² g or 123 g, a
  23% spread caused entirely by an unwritten convention.

icsf-8.png

* Half-up rounding builds in a bias - Instrument readings 0.5, 1.5,
  2.5, 3.5, 4.5 sum to 12.5. Rounded half-up, that's 1+2+3+4+5 = 15
  (+20%). Round-half-even gives 0+2+2+4+4 = 12 (−4%), and unbiased on
  average. Many ICSF courses teach half-up as the rule, and the
  instructor may or may not know that other conventions exist.

icsf-9.png

* Zero has no significant figures - By rule 3, leading zeros never
  count, so a balance reading "0.000 g" after taring has zero sig
  figs. Yet it is a precise statement: the mass is within ±0.0005 g of
  zero. Multiply it by anything and the rules give you an answer with
  no defined precision. Meanwhile 0.0010 g and 0.001000 g are
  described as having 2 and 4 sig figs, but nothing in the rules
  explains why "less than a milligram" should be treated as a
  different kind of number from "nothing."

icsf-10.png

Better Solutions

The best solution to ICSF specifically would be to write this on the board:

"Calculations, especially computer aided, can impart a false sense of accuracy. Use good judgment to avoid false accuracy problems."

Problem solved. At a high school chemistry level this is fine. But what of those who go on to become chemists or some profession which, apparently, takes accuracy even more seriously?

If you’re seriously serious, like NASA or NIST serious, you probably want to know about the ISO’s Joint Committee For Guides In Metrology. They have a subgroup, the Working Group on the Statement of Uncertainties, which publish JCGM 100. This document’s rigor and seriousness clearly highlight the deficiencies of ICSF methods. This specification has very detailed rationale for how to report values (how many digits do you show) and also for how to propagate values (how calculations are most accurately performed). A related standard is called "GUM (ISO/IEC Guide 98-3)"; GUM is "Guide to Uncertainties in Measurement" which certainly seems relevant.

Internalizing this standard is the way scientists at metrology labs must think about these problems, but there are more practical engineering approaches. A way these problems are competently solved is with an error budget. With this technique a table is made of uncertainty inputs along with their sensitivity coefficients and its share of the variance. This allows one to pay closer attention to the measurements that have the biggest impact on the desired outcomes.

Once the uncertainty has been managed it often needs to be communicated (the "reporting" part). While ICSF does this with clumsy digit counting, proper engineering documentation should use ASME Y14.5 - Dimensioning and Tolerancing

gdt.png

It was when I first saw geometric dimensioning and tolerancing used in engineering praxis that I became certain the ICSF heuristic I had been taught was badly flawed.

But things can get even worse. What if your uncertainty is nonlinear or even stochastic? For example what if I need to build a chain out of several batches of links whose variable size follow different complex distributions? Simply adding the links' lengths may not suffice. Some kind of Monte Carlo simulations may be needed to get an idea of the error of a full assembly.

Why This Bugs Me

So that’s my rant about Introductory Chemistry Significant Figures (ICSF). Why do I care so much?

I formally studied a STEM discipline that takes accuracy more seriously than chemistry seems to. My specific degree, industrial engineering, is the sub-specialty that explicitly focuses on such matters. As I became aware of more sophisticated ways to think about uncertainty, I could reevaluate my first weeks of chemistry class and see how deficient it was. I’ve got a whole different rant about the deficiencies of typical frequentist and Bayesian statistical methods generally, but at least with that we’re waiting for another Einstein level genius to fix it. With ICSF, it is simple to demonstrate a better way.

A huge part of my early career in manufacturing was all about taking accuracies seriously. As the factory computer nerd, I was often the ground truth. I learned to use a laser interferometer so that I could provide some of the highest accuracy our customers had access to. The more seriously I studied accuracy, the more ICSF became a joke.

Later in my career as a serious computer nerd I worked with some of the world’s most brilliant molecular biophysicists and biochemists at one of the top biotech universities. The painful irony was that my chemistry potential had been deeply stunted by my second week of high school chemistry. In those two weeks, I went from being interested in a career in plastics engineering to insisting on a career that had nothing whatsoever to do with chemistry. (Oh well.) All because of this topic which chemistry writ large would have done well to leave to the experts.

silly.jpg

I now see it as some kind of counterproductive hazing. If it was simply a matter of not being able to run that Python script in my brain perhaps I would have considered the game fair. But even as a 15 year old kid I could smell the epistemological problems with ICSF. This was badly exacerbated by the teacher, let’s call him Fundy McBlowhard, who was a serious and frequent violator of The Law. I’m thankful that my robot friends allowed me to explore the reality of the situation better without the need to seriously study a questionable thing that I probably should not have ever studied.

Chimneysweep

2026-09-21 09:49

Last year at this time I was installing a chimney. That turned out very well and survived a very punishing winter. Overall I probably had a fire lit for six solid months of the last year. The warm weather is winding down, and last weekend I cleaned the chimney.

the chimney

This post is a benchmark reference for people who have a chimney and are wondering if they should clean their own. The short answer is: probably yes.

I ordered this chimney brush kit for $22 (now $23). The rods were a bit floppier than I expected and I was nervous about them coming unfastened inside the chimney leaving a bigger problem. The product anticipated this and the instructions strongly encouraged taping each joint with electrical tape, which was provided. This of course makes assembly and disassembly more irritating but I’m just storing three 5' sections to minimize future hassle.

One might imagine, as I did, that using this tool would involve spinning it with a drill. But because of the flexibility of the rods and the fact that it all had to be taped before using, that strategy was not so obvious. Fortunately, just poking the brush up there seemed fine for dislodging most of what needed to be cleaned out. I did chuck it up once it was fully inside the chimney and with an assistant walking away with the drill we managed to get some gentle rotation. Just note that its usage is not entirely obvious.

I have 23 feet of chimney from the stove to the cap. The design of mine is deliberately quite easy to clean. I just take out the plug at the bottom of the tee and go up 17 feet and then go horizontally into the house a few feet. Then I had to pull the baffle out of the stove and do the last indoor vertical section with one of the aforementioned 5' sections.

So how did that go?

The first thing to note is that a lot of ash had accumulated in the tee right at the cleanout plug. If your chimney drops straight down into your fire, maybe this is much less of a factor. But when I opened the cleanout plug I could see I was in danger of the entire chimney getting completely blocked with ash.

cleanout_plugged1.jpg

Here’s a closer look at that.

cleanout_plugged2.jpg

Here’s what I collected from outside.

ash_in_bin.jpg

This operation is obviously pretty messy but I was able to contain the mess pretty well. I will remember to wear gloves next time.

20260914_mess.jpg

It’s difficult to appreciate what exactly is in there. Ash is very light and compressible. So I collected all the ash removed from the chimney and put it in a container I could weigh.

ash_weigh.jpg

Here is the empty and full weights.

scale.jpg

That is about 1.6kg [3.6lbs]. Which is about 70g of ash per foot of chimney run during the 6 months. That is an accumulation rate of about 16mg/hr.

Here are some things to consider when deciding if you should clean your chimney.

  • Are you a power user? Do you have a fire burning all winter long? Or have you had a more modest usage but have gone many seasons without a cleaning?

  • Are there bends where ash can collect? The top of stove baffles count too!

  • Are you burning sketchy high moisture wood sometimes?

  • Can you afford to just have a dude show up and clean it?

If you answered yes to some of these questions and don’t like dying in housefires, I would recommend cleaning your chimney.

My Second Favorite Game Of All TIme

2026-09-09 19:14

Isn’t it weird that two of my least favorite companies are owners of my favorite two games? Today one of them, Ubisoft, sent me an email asking for my opinions about my favorite AAA franchise FARCRY. Even though Ubisoft is perfidious and all interaction with them has shown them to be evil, I do enjoy FC games and I am quite qualified to give feedback.

This is mostly to show off my sardonic but completely true one sentence review of my second-favorite game of all time.

fcsurvey.png

After sitting in my Library for several years, I finally got through the TR games last winter. The other games I list are hints about what is by far my favorite game of all time and who the other despised company is. It also is a hint about a very interesting project I’ve started working on.

fc5-josephseed.jpg

Blender Measurement Fixed

2026-09-02 14:09

Blender is an amazing piece of software and I love it but there are some things which just drive me crazy! Usually the deficiency is actually mine. Once I finally learn some esoteric trick it does turn out that Blender made sense all along and I was just ignorant. My biggest complaint, however, does not involve my clumsiness. I know this because over the last 10 years that I’ve been studying Blender, it has improved dramatically in this area — and it’s still terrible!

The functionality I’m highlighting today is simple measurement. I’m not asking Blender to do the math; no, I know it has already done the math and I just want it to tell me what the answer is! If I put together an excruciatingly careful model I may have questions like, how big of a box would I need to contain this whole model? Will my canoe fit in through the doorway? If this bike seat is correctly modeled with my whole bike, how high off the ground is it? Stuff like that.

The cultural disconnect here is that while this is probably the most important function for a CAD system, for a 3d art platform it is much lower on the list of priorities. Blender artists would be inclined to just model a box to see how big one needs to be to fit a model. They would just make a quick doorway and see if the canoe looked like it fit. It’s good to be able to do that when it confers advantages, but sometimes you just need to know how high the bike saddle is off the ground without modeling a square bar also that high for reference. It is embarrassing to both me and Blender how many times I’ve had to make such reference objects just to find out how big something is.

Blender actually has several ways to get distance information out of a model. All of them have problems.

For starters, you can turn on Mesh Edit Mode Overlays so that edges are numerically annotated with how long they are. That is dynamic and pretty hands off, but the setup is fussy and I find it only solves about 5% of real measurement questions. The precision is seemingly random. And look how crazy tiny these numbers are — no way to control it that I know.

Mesh Edit Mode Overlays

This edge marking can, in theory, help you find errors like the one where my boolean cutting operation left a 0.0133" edge. But that can be hard to spot — did you catch it? (There are two, for example, the far left numerical value.)

In modern Blender versions, there is the promising Measurement Tool. Its icon has two rulers in an L.

The Blender Measure Tool

Here are things I do not like about it.

  • Requires the T (tool) menu. As far as I know there are no key bindings for this making it instantly unsuitable for anyone competent.

  • You must hold down the mouse button between the first and second measurement points. Besides not being how things like this are normally done in Blender, there’s a more serious issue. Imagine you’re trying to pick a point in a complex mess that you’ve zoomed in on and the other point is in a complex mess far away on your model and now off screen. How do you even handle that?

  • I’m sure there is a strategy for how many digits of precision this tool produces, but the fact that I don’t know what it is makes me crazy. This is especially rough when trying to measure a model to find out if your 2x4 is really 3.5000 inches or actually 3.5072 in the model. Good models do not have such inaccuracies!

Measure Tool Precision Mess

  • It leaves the measurement behind as some kind of permanent artifact. Let me stress this: you can not simply check on the dimensions of something in the model without adding to the model! The times when I would want this kind of permanent record of a measurement, I would go with the MeasureIt addon which is yet another ponderous way one can get measurements. The advantage of that tool is that it makes somewhat decent blueprint style dimension lines, better than the Measure Tool’s which are invisible on render. Here’s an example of MeasureIt.

MeasureIt Sample

  • The key bindings and operation of the Measurement Tool are idiosyncratic. When the snap is in effect is odd; when would not having a clear snap make any sense for a measurement that’s intended to be better than just sizing it up visually?

  • It’s mostly impossible to decompose measurements between points only along a particular axis. For example if I measured from a point at the peak of a house roof to a point at the gutter, I know how far rain might travel, but I have no clue about how much higher the roof makes the structure. For that, I want those same points but I only want the distance in the up/down (Z) direction.

Yesterday I was working on one of my custom table designs (which I invented using 3d computer modeling in 1990!) and it is exactly the kind of project that requires the ability to easily do complex measurements. It was driving me crazy!

What is so maddening about it is there is another way to get distance. You can open up a Python console, put the 3d cursor on the first point, then the second point while typing these commands.

p1= C.scene.cursor_location.copy()
p2= C.scene.cursor_location.copy()
print(p2-p1)

That’s it! Like I say, Blender has already done the math! I just need it to tell me about it. But this Python interface is not so fun. Can it be improved? Turns out, yes!

I had some astonishingly good results with Anthropic’s Claude recently and so I made a very detailed careful prompt of exactly what I wanted and how it should work and presto! That’s what I got. This was kind of a perfect mission for it since it has read all of the add-ons and my proposed add-on was so simple and basic that all of the challenge is just producing a sane basic add-on code template. If I had to do this without robot help I would have had to do what Claude did — read enough add-on source code until I could strip away everything that wasn’t common to all of them and then add my ridiculously simple core logic, logic that I worked out long before there were robot helpers.

I’m calling my addon Xed Dist so that I can easily recognize it in the aforementioned crowd of measurement tools. Here’s what its interface looks like.

Xed Dist Interface

There are basically two identical input regions for the two endpoints of a measurement. You can type in the numbers yourself if you know some arbitrary reference point in your model that may not have any geometry. But the more common thing to do would be to fill these fields in by picking at features in your model. Unlike the stock Measure Tool, my system collects points in exactly the same way you specify them in the majority of Blender operations. In the image, for example, I am in vertex edit mode and I selected the bottom (orange) vertex and clicked the Active Vertex button for Point A. Then I held shift and clicked the top (white) vertex) and clicked the Active Vertex button for B. Done. That was it.

The results update whenever the points are changed. How tall is this table? The Z component of the A-B say its 28.5". How would I ever get this out of Blender’s stock measuring tools? But I’m also seeing the full distance as well as verifying that the Y is 0 letting me know I didn’t accidentally stray from my intended plane during modeling.

There are also angles shown. This is more complex but for simple cases like this one it is helpful. The 45deg is confirmed just as the X component matching the Z height suggests it should be.

The Zero button simply sets a point to the world origin, all zeros. The Cursor button is more useful. You can type Shift-S and 2 (standard Blender expert stuff) to put the 3d cursor on a particular vertex, or, better, the average of a collection of them (a good way to get a midpoint). It is also very helpful for non-model geometry questions too, like checking the exact distance between a light source and a camera or just how far above the camera the light source is.

In between the two Point input regions are two buttons. The Swap A/B does the simple thing it promises. The Custom Axes is more complicated but still not too bad because instead of relying on some internal Blender concepts it relies on your model and your understanding of it.

Let’s say I wanted to know how long to cut each of those boards that make up the table. Basically I need to know the maximum length of any part of the object along the long axis. But since the long axis is up on an angle, decomposing the distance vector won’t help. One thing I could do is grab this object, temporarily rotate it flat by 45deg, take my measurement, and then rotate it back. But the Custom Axes allows you to load A with a local axis origin and B with a point on the positive X of a local axis. Basically if I put in the two points shown above and click custom axis, the measurements will be given with respect to that axis. If you’re good at 3d geometry, you’ll know that two points is insufficient to really make a proper coordinate system. The 3d cursor can be used to be a third point on the new local X-Y plane.

Xed Dist Interface - Local

So if the A and B points were chosen as shown in the previous image and the 3d cursor set as shown, after using the Custom Axes button, when I set A and B to the points shown now, the measurements say Local! and show us that this is indeed a 2x4 (3.5in by 1.5in) because even on this angle, in the local system, the components decompose to the board’s orthogonal sense. We can also see that the overall local X is about 43.8 which is what we’d need to cut from a 2x4 to get a long enough piece.

When specifying the third plane point for this feature, it can be helpful to Shift-RMB the 3d cursor onto any surface. Also when you’re done with the Custom Axes and want to return to world axes, just hit the Zero button on both Point A and B and then hit the Custom Axes button again.

A subtle thing you may have noticed if you are a careful modeler who keeps an eye on numerical details is that there is something fishy in the Y value input box of Point A. Why is it 0.98"? It is a typical capricious erroneous Blender rounding problem. We can see in the Results section that the local Z (which is now aligned with world Y) is correctly the width of a 2x4 at 1.5". Shouldn’t the 2.5" shown in Point B’s Y input box minus this 1.5 be exactly 1? It turns out that this particular design has all the table legs shifted in their local Z (the world Y for this one) by 0.025" so that everything lines up correctly. The Y on Point A is actually 0.975, not 0.98 as shown. Much worse, the Y on Point B is not 2.5 as shown but rather 2.475"! This interface just uses the normal Blender input boxes which commonly show a totally different value than you will see if you actually click on it to make an edit. This is why you can’t even trust glancing up at the Item tab’s Vertex display and competently just doing the math in your head! This is yet another reason that this add-on with its stable full precision results section is such a critical improvement.

textboxwoes.png

This is also why I’ve included a generous amount of displayed digits in the results section. You can set this in the code to be whatever you want, but I personally want a way to know everything that Blender knows where practical. There are errors that arise due to the way floating point numbers are encoded and some imperfections come with the territory, but seeing all the decimals can really help spot when something is out of whack because you made a tiny invisible slip up.

One of the reasons that I have input boxes at all is that besides being a reasonable way to show the location of the selected points, it is also a familiar interface with a lot of excellent functionality. Let’s say you’re working on a house frame model and you want to know how far it is from the window opening to the center of the room. The room you can get by selecting its floor rectangle in face mode, putting the 3d cursor on it (Shift-S + 2) and clicking the Cursor button. But let’s say you haven’t framed the window yet. You know the window opening will be one stud from the last you’ve placed plus a supporting king stud. You can select the farthest vertex of the last stud and then go to the input box for that wall’s axis and edit that value by appending + 16 + 1.5. This will put your measurement point somewhere you know is important but where nothing actually exists yet. Other useful things to add might be / 25.4 to put a mm size into your non-metric model. It’s basically a handy calculator.

As you can see with my requirement to deal with 2x4’s I have to suffer gringo units and having good feedback that simply provides Blender native meters and the units I often need to use is helpful. I think even for non-US people it could be nice to note that an odd measurement like 63.5mm is actually 2.5 Freedom Inches.

I don’t know if I’ll ever bother making a project page out of this. Not much of it is my code anyway. It is so ridiculously simple you can just tell your robot friend to make one for you. Heck, in the future, you should be able to give the link of this post to your robot friend and tell it what custom features you’d like. But for my archiving purposes, I’m going to dump the whole source code here for my future self and anyone who might need something like this.

bl_info = {
    "name": "Xed Dist",
    "author": "Chris X Edwards and robot friend Claude",
    "version": (1, 0, 0),
    "blender": (5, 2, 0),
    "location": "View3D > Sidebar (N-panel) > Xed Dist",
    "description": "Replaces the built-in Measure tool with typed-in vertex "
                   "coordinate boxes, live distance/axis-delta/angle output, buttons to:"
                   "swap A/B and to load point from: 3d cursor, origin, and active vertex.",
                   "Also load a custom axes by setting A to X=0, B to X+ value, and the",
                   "3d Cursor somewhere on the XY plane."
    "category": "3D View",
}

import bpy
import bmesh
import math
from mathutils import Vector
from bpy.props import FloatVectorProperty, PointerProperty, EnumProperty
from bpy.types import PropertyGroup, Operator, Panel

# == Helpers ==
def get_active_vertex_world_co(context):
    """Return world-space coordinates of the active vertex in edit mode, or None."""
    obj = context.edit_object
    if obj is None or obj.type != 'MESH':
        return None
    bm = bmesh.from_edit_mesh(obj.data)
    bm.verts.ensure_lookup_table()
    active_vert = None
    # Prefer the true "active" element from the selection history.
    if bm.select_history:
        elem = bm.select_history[-1]
        if isinstance(elem, bmesh.types.BMVert):
            active_vert = elem
        elif hasattr(elem, "verts") and elem.verts:
            # Active edge/face selected: fall back to its last vertex.
            active_vert = elem.verts[-1]
    if active_vert is None:
        # Fall back to the last selected vertex found.
        selected = [v for v in bm.verts if v.select]
        if selected:
            active_vert = selected[-1]
    if active_vert is None:
        return None
    return obj.matrix_world @ active_vert.co

def _wrapped_degrees(angle_rad):
    deg = math.degrees(angle_rad)
    if deg < 0:
        deg += 360.0
    return deg

# == Property Group ==
class MEASURETOOL_Properties(PropertyGroup):
    point_a: FloatVectorProperty(
        name="Point A",
        description="First measurement point. Accepts normal Blender typed "
                    "math in each field (e.g. click in, type '-1.5', Enter)",
        subtype='XYZ',
        unit='LENGTH',
        size=3,
    )
    point_b: FloatVectorProperty(
        name="Point B",
        description="Second measurement point. Accepts normal Blender typed "
                    "math in each field (e.g. click in, type '-1.5', Enter)",
        subtype='XYZ',
        unit='LENGTH',
        size=3,
    )
    local_x_axis: FloatVectorProperty(size=3, subtype='XYZ', default=(1.0, 0.0, 0.0))
    local_y_axis: FloatVectorProperty(size=3, subtype='XYZ', default=(0.0, 1.0, 0.0))
    local_z_axis: FloatVectorProperty(size=3, subtype='XYZ', default=(0.0, 0.0, 1.0))
    use_local_frame: bpy.props.BoolProperty(default=False)

# == Operators ==
class MEASURETOOL_OT_assign_active(Operator):
    bl_idname = "measuretool.assign_active"
    bl_label = "Assign Active Vertex"
    bl_description = "Copy the active vertex's world-space position into this point"
    bl_options = {'REGISTER', 'UNDO'}
    target: EnumProperty(items=[('A', "A", ""), ('B', "B", "")], default='A')
    @classmethod
    def poll(cls, context):
        return context.mode == 'EDIT_MESH' and context.edit_object is not None
    def execute(self, context):
        co = get_active_vertex_world_co(context)
        if co is None:
            self.report({'WARNING'}, "No active vertex found")
            return {'CANCELLED'}
        props = context.scene.measuretool_props
        if self.target == 'A':
            props.point_a = co
        else:
            props.point_b = co
        return {'FINISHED'}

class MEASURETOOL_OT_zero(Operator):
    bl_idname = "measuretool.zero"
    bl_label = "Zero to Origin"
    bl_description = "Set this point to the world origin (0, 0, 0)"
    bl_options = {'REGISTER', 'UNDO'}
    target: EnumProperty(items=[('A', "A", ""), ('B', "B", "")], default='A')
    def execute(self, context):
        props = context.scene.measuretool_props
        if self.target == 'A':
            props.point_a = (0.0, 0.0, 0.0)
        else:
            props.point_b = (0.0, 0.0, 0.0)
        return {'FINISHED'}

class MEASURETOOL_OT_swap(Operator):
    bl_idname = "measuretool.swap"
    bl_label = "Swap A / B"
    bl_description = "Swap the values of Point A and Point B"
    bl_options = {'REGISTER', 'UNDO'}
    def execute(self, context):
        props = context.scene.measuretool_props
        a = tuple(props.point_a)
        b = tuple(props.point_b)
        props.point_a = b
        props.point_b = a
        return {'FINISHED'}

class MEASURETOOL_OT_cursor_to_point(Operator):
    bl_idname = "measuretool.cursor_to_point"
    bl_label = "3D Cursor to Point"
    bl_description = "Copy the 3D cursor's location into this point"
    bl_options = {'REGISTER', 'UNDO'}
    target: EnumProperty(items=[('A', "A", ""), ('B', "B", "")], default='A')
    def execute(self, context):
        props = context.scene.measuretool_props
        cursor_co = context.scene.cursor.location
        if self.target == 'A':
            props.point_a = cursor_co
        else:
            props.point_b = cursor_co
        return {'FINISHED'}

class MEASURETOOL_OT_custom_axes(Operator):
    bl_idname = "measuretool.custom_axes"
    bl_label = "Custom Axes (A->B = +X, Cursor on XY)"
    bl_description = ("Build a local frame where A->B is +X and the 3D cursor "
                       "lies in the XY plane. Delta values then read in this "
                       "frame instead of world space. Zero A, zero B, and "
                       "click again to reset to world axes.")
    bl_options = {'REGISTER', 'UNDO'}
    EPS = 1e-8
    def execute(self, context):
        props = context.scene.measuretool_props
        a = Vector(props.point_a)
        b = Vector(props.point_b)
        cursor = context.scene.cursor.location
        ab = b - a
        if ab.length < self.EPS:
            props.use_local_frame = False
            self.report({'INFO'}, "Reset to world axes")
            return {'FINISHED'}
        local_x = ab.normalized()
        ac = cursor - a
        proj = ac - ac.dot(local_x) * local_x
        if proj.length < self.EPS:
            self.report({'ERROR'}, "3D cursor is colinear with A-B; can't define a plane")
            return {'CANCELLED'}
        local_y = proj.normalized()
        local_z = local_x.cross(local_y)
        props.local_x_axis = local_x
        props.local_y_axis = local_y
        props.local_z_axis = local_z
        props.use_local_frame = True
        return {'FINISHED'}

# == Panel ==
class MEASURETOOL_PT_panel(Panel):
    bl_label = "Xed Dist"
    bl_idname = "MEASURETOOL_PT_panel"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "Measure"

    def draw(self, context):
        layout = self.layout
        props = context.scene.measuretool_props
        # === Point A ===
        box = layout.box()
        box.label(text="Point A")
        box.column().prop(props, "point_a", text="")
        row = box.row(align=True)
        row.operator("measuretool.assign_active", text="Active Vertex").target = 'A'
        row.operator("measuretool.zero", text="Zero").target = 'A'
        row.operator("measuretool.cursor_to_point", text="Cursor", icon='CURSOR').target = 'A'
        layout.operator("measuretool.swap", icon='ARROW_LEFTRIGHT')
        layout.operator("measuretool.custom_axes", icon='ORIENTATION_LOCAL')
        # === Point B ===
        box = layout.box()
        box.label(text="Point B")
        box.column().prop(props, "point_b", text="")
        row = box.row(align=True)
        row.operator("measuretool.assign_active", text="Active Vertex").target = 'B'
        row.operator("measuretool.zero", text="Zero").target = 'B'
        row.operator("measuretool.cursor_to_point", text="Cursor", icon='CURSOR').target = 'B'
        # === Results ===
        a = Vector(props.point_a)
        b = Vector(props.point_b)
        d = b - a
        distance = d.length
        dx, dy, dz = abs(d.x), abs(d.y), abs(d.z)
        # Angles: for the plane perpendicular to a given axis, measured CCW
        # starting at the first of the two remaining axes in the cyclic
        # order X -> Y -> Z -> X, so a positive angle sweeps toward the
        # second axis (right-hand rule about the axis you're "looking
        # down"). E.g. looking down Z: 0deg = +X, 90deg = +Y (matches spec).
        angle_z = _wrapped_degrees(math.atan2(d.y, d.x))  # look down Z, 0=+X, 90=+Y
        angle_y = _wrapped_degrees(math.atan2(d.x, d.z))  # look down Y, 0=+Z, 90=+X
        angle_x = _wrapped_degrees(math.atan2(d.z, d.y))  # look down X, 0=+Y, 90=+Z
        box = layout.box()
        box.label(text="Results", icon='DRIVER_DISTANCE')
        col = box.column(align=True)
        col.label(text=f"Dist: {distance:.6f} ({distance/0.0254:.6f}\")")
        if props.use_local_frame:
            lx = Vector(props.local_x_axis)
            ly = Vector(props.local_y_axis)
            lz = Vector(props.local_z_axis)
            dx, dy, dz = abs(d.dot(lx)), abs(d.dot(ly)), abs(d.dot(lz))
            axis_label = "Local! A-B ("
        else:
            dx, dy, dz = abs(d.x), abs(d.y), abs(d.z)
            axis_label = "A-B ("
        col.label(text=f"{axis_label} X): {dx:.7f} ({dx/0.0254:.6F}\")")
        col.label(text=f"{axis_label} Y): {dy:.7f} ({dy/0.0254:.6F}\")")
        col.label(text=f"{axis_label} Z): {dz:.7f} ({dz/0.0254:.6F}\")")
        col = box.column(align=True)
        col.separator()
        col.label(text=f"Angle (down Z, 0°=+X→+Y): {angle_z:.6f}°")
        col.label(text=f"Angle (down Y, 0°=+Z→+X): {angle_y:.6f}°")
        col.label(text=f"Angle (down X, 0°=+Y→+Z): {angle_x:.6f}°")

# == Registration ==
classes = (
    MEASURETOOL_Properties,
    MEASURETOOL_OT_assign_active,
    MEASURETOOL_OT_zero,
    MEASURETOOL_OT_swap,
    MEASURETOOL_PT_panel,
    MEASURETOOL_OT_cursor_to_point,
    MEASURETOOL_OT_custom_axes,
)

def register():
    for cls in classes:
        bpy.utils.register_class(cls)
    bpy.types.Scene.measuretool_props = PointerProperty(type=MEASURETOOL_Properties)

def unregister():
    del bpy.types.Scene.measuretool_props
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)

if __name__ == "__main__":
    register()

To get it working, save this code as something like xeddist.py. Then in Blender, open Preferences (Ctrl-,) then go to the Add-ons section. They’ve made some major changes here recently (after v5) — you have to click on the little V symbol in the far upper right corner and choose Install from Disk… from the pull down. Then find the Python file and that should be it - it should show up in the interface as described.

tableirl.jpg

--------------------------

For older posts and RSS feed see the blog archives.
Chris X Edwards © 1999-2026